Introduction
Nix libraries often grow into small languages. A user declares entities, reusable aspects, target classes, dependencies, and policies; a pipeline turns that declarative graph into concrete Nix modules, files, packages, or checks. Without a shared structure, every layer writes its own walker: one for validation, one for documentation, one for dependency ordering, one for rendering, one for diagnostics.
nix-effects is a typed, description-backed programming substrate for
pure Nix. Effects are the execution model; generated descriptions are
the shared structure; the kernel checks boundaries; generic tools,
diagnostics, proofs, and ornaments reuse the same shape. Everything runs
at nix eval time, before anything builds or ships.
The unusual part is that the kernel is hosted inside Nix evaluation itself. Nix normally gives libraries functions, attrsets, assertions, and module option types. nix-effects adds typed descriptions that generic programs can inspect. Validation is therefore not a hand-written walker: it is one interpretation of a type description, next to schemas, documentation, dependency extraction, and DSL interpretation.
The type layer is backed by a Martin-Löf dependent type checker in
src/tc/ with Pi, Sigma, identity types with J, explicit universe
levels, HOAS elaboration, generated datatypes, and verified extraction
of plain Nix functions from proof terms. Descriptions provide reusable
datatype shapes, so domain DSLs can be validated, interpreted,
documented, transformed, or extracted by generic tools instead of
one-off traversals.
The bidirectional checker sends typeCheck effects carrying a
field-path context, so type errors in deeply nested terms come back
localized to the field that broke.
What it looks like
A target class is one of a small set of strings. In nix-effects, that is a refinement type:
let
inherit (fx.types) String refined;
TargetClass = refined "TargetClass" String
(x: builtins.elem x [ "module" "file" "package" "check" ]);
in {
ok = TargetClass.check "module"; # true
bad = TargetClass.check "fleet"; # false
}
Behind the scenes, .check runs the MLTT kernel's decision procedure
for the base type, then evaluates the refinement predicate. For a
refinement type like TargetClass, this is fast: the kernel confirms a
string, the guard confirms membership in the known target classes. You
write normal Nix and the kernel runs behind the scenes.
But checking individual values is only the starting point. The same kernel-backed structure supports derived validation for compound shapes and can also verify entire functions, then extract them as ordinary Nix functions.
Verified functions over real data
Write an implementation in HOAS (Higher-Order Abstract Syntax), the
kernel type-checks it, and v.verify extracts a callable Nix function.
Here is a small aspect declaration predicate. The kernel verifies a
function that takes a record with name, target, and requires
fields, then checks that the target is one of the classes this pipeline
knows how to render. The check uses string comparison inside the kernel
— strEq is a kernel primitive, not a Nix-level hack.
let
H = fx.types.hoas;
v = fx.types.verified;
AspectDecl = H.record [
{ name = "name"; type = H.string; }
{ name = "target"; type = H.string; }
{ name = "requires"; type = H.listOf H.string; }
];
targets =
H.cons H.string (v.str "module")
(H.cons H.string (v.str "file")
(H.cons H.string (v.str "package")
(H.cons H.string (v.str "check") (H.nil H.string))));
validateAspect = v.verify (H.forall "a" AspectDecl (_: H.bool))
(v.fn "a" AspectDecl (a:
v.strElem (v.field AspectDecl "target" a) targets));
in {
ok = validateAspect {
name = "workspace-shell";
target = "module";
requires = [ "toolchain" ];
}; # true
bad = validateAspect {
name = "workspace-aspect";
target = "fleet";
requires = [ ];
}; # false
}
validateAspect is a plain Nix function. You call it with a plain Nix
attrset. But the implementation was verified by the MLTT kernel before
extraction — the kernel confirmed that the function matches its type
(AspectDecl → Bool), that field projections are well-typed, and that
the string membership check composes correctly. If you made a type error
in the implementation — say, compared a Bool where a String was
expected — the kernel would reject it at nix eval time.
The record type (H.record) elaborates to nested Sigma in the kernel.
v.field desugars to the right chain of first/second projections.
v.strEq is a kernel primitive that reduces strEq "foo" "foo" to
true during normalization. v.strElem folds over a list with strEq.
None of this is Nix-level string comparison — it's computation inside
the proof checker.
Proofs as programs
The same kernel that verifies functions also checks mathematical
proofs. Both are the same judgment — Γ ⊢ t : T — applied
differently. A verified function proves that an implementation inhabits
its type. An equality proof proves that two expressions reduce to the
same normal form.
let
H = fx.types.hoas;
v = fx.types.verified;
# Verified addition: Nat → Nat → Nat by structural recursion
add = v.verify (H.forall "m" H.nat (_: H.forall "n" H.nat (_: H.nat)))
(v.fn "m" H.nat (m: v.fn "n" H.nat (n:
v.match H.nat m {
zero = n;
succ = _k: ih: H.succ ih;
})));
in {
five = add 2 3; # 5
# Prove 3 + 5 = 8: the kernel normalizes both sides, Refl witnesses equality
proof = (H.checkHoas (H.eq H.nat (add (H.natLit 3) (H.natLit 5)) (H.natLit 8))
H.refl).tag == "refl"; # true
}
The add function is extracted exactly like validateAspect — write in
HOAS, kernel checks, extract a Nix function. The equality proof goes
one step further: the kernel normalizes add(3, 5) by running the
structural recursion, arrives at 8, and confirms Refl witnesses
8 = 8. This is computational proof — the kernel computes the answer
and verifies that computation agrees with the claim.
The effect system
The "effects" in nix-effects are algebraic effects implemented via a freer monad (Kiselyov & Ishii 2015). A computation is a tree of effects with continuations. A handler walks the tree, interpreting each effect:
let
inherit (fx) pure bind run;
inherit (fx.effects) get put;
# Read state, double it, write it back
doubleState = bind get (s: bind (put (s * 2)) (_: pure s));
result = run doubleState fx.effects.state.handler 21;
# result.value = 21, result.state = 42
in result
This matters for type checking because it separates what to check
from how to report. When DepRecord.validate finds a type error,
it sends a typeCheck effect. The handler decides the policy:
- Strict — abort on the first error
- Collecting — gather all errors, keep checking
- Logging — record every check, pass or fail
Same validation logic, different handler. The checker reports through the same effect substrate, so validation policy composes with the rest of the effect system.
The verification spectrum
Not everything needs proofs. nix-effects supports four levels of assurance, and you pick the one that fits:
Level 1 — Contract. Write normal Nix. Types check values via
.check. The kernel runs behind the scenes. Zero cost to adopt.
TargetClass = refined "TargetClass" String
(x: builtins.elem x [ "module" "file" "package" "check" ]);
TargetClass.check "module" # true
Level 2 — Boundary. Data is checked by the kernel at module
interfaces. Every type has a kernelType and .check is derived from
the kernel's decide procedure. This is what all built-in types do by
default — (ListOf String).check ["a" "b"] elaborates the list into a
kernel term and type-checks it.
Level 3 — Property. Write proof terms in HOAS that the kernel
verifies. Prove that 3 + 5 = 8, or that double negation on booleans
is the identity, or that append([1,2], [3]) = [1,2,3].
Level 4 — Full. The implementation IS the proof term. Write in
HOAS, the kernel verifies, extract produces a Nix function correct by
construction. The validateAspect example above is Level 4 — the kernel
verified the validator before extracting it as a callable function.
Most users will stay at levels 1 and 2. The kernel is there when you need it. With record types and string comparison now in the kernel, Level 4 handles real-world validation — not just arithmetic on natural numbers.
How this document is organized
The rest of the guide builds up from here:
- Getting Started sets up the library and shows the first type, effect, and generated datatype.
- Effects and Handlers explains the execution model: computations send operations, handlers choose policy.
- Typed Validation covers primitive, refined, structured, and dependent validation boundaries.
- Generated Datatypes introduces description-backed data as the shared structure.
- Generic Programming shows how schemas, dependency graphs, diagnostics, and views derive from that structure.
- Ornaments and Description-Backed Data refines generated shapes while preserving forgetful maps.
- Proof Guide builds proofs and verified implementations from kernel-checked HOAS terms.
- Theory explains the ideas behind the model.
- The internals chapters document the trampoline, architecture, kernel implementation, and formal specification for contributors.
References
Martin-Lof, P. (1984). Intuitionistic Type Theory. Bibliopolis.
Kiselyov, O., & Ishii, H. (2015). Freer Monads, More Extensible Effects. Haskell Symposium 2015. [pdf]
Plotkin, G., & Pretnar, M. (2009). Handlers of Algebraic Effects. ESOP 2009. [doi]
Findler, R., & Felleisen, M. (2002). Contracts for Higher-Order Functions. ICFP 2002. [doi]