Introduction
Nix configurations fail late. A misspelled option name, a string where an
integer belongs, a firewall rule that references a port no service
listens on — these surface at build time, at deploy time, or when
a user files a ticket. The NixOS module system catches some of this
with mkOption and types.str, but the checking is shallow: it
validates individual fields, not relationships between them. "The build
system must appear in the declared platforms list" is a constraint no
existing Nix tool can express as a type.
nix-effects is a type-checking kernel, an algebraic effect system, and
a dependent type library, implemented entirely in pure Nix. It catches
configuration errors at nix eval time — before anything builds or
ships. The kernel can verify that a validator function is correct,
then extract it as an ordinary Nix function you call on plain attrsets.
What it looks like
A port number is an integer between 1 and 65535. In nix-effects, that's a refinement type:
let
inherit (fx.types) Int refined;
Port = refined "Port" Int (x: x >= 1 && x <= 65535);
in {
ok = Port.check 8080; # true
bad = Port.check 99999; # false
}
Behind the scenes, .check runs the MLTT kernel's decision procedure —
the value is elaborated into a kernel term, type-checked, and the
predicate is evaluated. For a refinement type like Port, this is fast:
the kernel confirms the base type (Int), the guard confirms the range.
You write normal Nix and the kernel runs behind the scenes.
But checking individual values is only the starting point. The kernel can also verify entire functions — confirm that a validator you wrote is type-correct, then extract it as an ordinary Nix function.
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's a derivation spec validator. The kernel verifies a function that
takes a record with license, platforms, and system fields, then
checks three constraints: the build system appears in the platforms
list, the system is one of the supported architectures, and the license
is approved. All three checks use string comparison inside the kernel
— strEq is a kernel primitive, not a Nix-level hack.
let
H = fx.types.hoas;
v = fx.types.verified;
Spec = H.record [
{ name = "license"; type = H.string; }
{ name = "platforms"; type = H.listOf H.string; }
{ name = "system"; type = H.string; }
];
licenses = mkStrList [ "MIT" "Apache-2.0" "BSD-3-Clause" ];
systems = mkStrList [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" ];
# Kernel-verified: system ∈ platforms AND system ∈ supported AND license ∈ approved
validateSpec = v.verify (H.forall "s" Spec (_: H.bool))
(v.fn "s" Spec (s:
v.if_ H.bool (v.strElem (v.field Spec "system" s)
(v.field Spec "platforms" s)) {
then_ = v.if_ H.bool (v.strElem (v.field Spec "system" s) systems) {
then_ = v.strElem (v.field Spec "license" s) licenses;
else_ = v.false_;
};
else_ = v.false_;
}));
in {
ok = validateSpec {
system = "x86_64-linux"; license = "MIT";
platforms = [ "x86_64-linux" "aarch64-linux" ];
}; # true
bad = validateSpec {
system = "arm64"; license = "MIT";
platforms = [ "x86_64-linux" ];
}; # false — arm64 not in supported systems
mismatch = validateSpec {
system = "x86_64-linux"; license = "MIT";
platforms = [ "aarch64-linux" ];
}; # false — system not in its own platforms list
}
validateSpec 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
(Spec → Bool), that field projections are well-typed, and that the
string membership checks compose 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 validateSpec — 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 type-checking kernel itself runs as an effectful computation on this same infrastructure — the kernel is just another program running on the effects substrate.
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.
Port = refined "Port" Int (x: x >= 1 && x <= 65535);
Port.check 8080 # 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 validateSpec 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 walks through installation, your first type, your first effect, and the end-to-end derivation demo.
- Proof Guide builds proofs incrementally — from computational equality through the J eliminator to proof-gated derivations that reject invalid configurations at eval time.
- Theory covers the nine papers that shaped the design — algebraic effects, freer monads, dependent types, refinement types, the Fire Triangle, and why they fit together in pure Nix.
- Trampoline explains how
builtins.genericClosurebecomes a trampoline for stack-safe evaluation at scale. - Systems Architecture describes the kernel-first design: one notion of type, one checking mechanism, no adequacy bridge.
- Kernel Architecture details the MLTT kernel internals — NbE, bidirectional checking, HOAS elaboration, extraction, and the trust model.
- Kernel Specification gives the formal typing rules.
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]