Proof Guide
Nix configurations are concrete at eval time. Every field, every value,
every list element is known before anything builds. The nix-effects
kernel exploits this: it normalizes both sides of an equation via NbE,
and if they reduce to the same value, Refl proves them equal. No
symbolic reasoning, no induction over unknowns — just computation on
concrete data, checked by a type-theoretic kernel implemented in 1,300
lines of pure Nix.
This chapter builds proofs incrementally, from 0 + 0 = 0 through
the J eliminator to a production service builder where invalid
configurations are rejected by a machine-checked proof. Every example
is runnable. The code comes from three files in the repository:
proof-basics.nix,
equality-proofs.nix, and
verified-functions.nix.
Prerequisites. You should know what a function is and what let
bindings do in Nix. Familiarity with the Getting Started chapter helps
but isn't required. You do not need to know type theory.
Your first proof
A proof in nix-effects is a term that type-checks against an equality
type. The simplest equality type is Eq(Nat, 0+0, 0) — the claim
that adding zero to zero produces zero. The proof term is Refl, which
says "both sides are the same." The kernel checks this by normalizing
0 + 0, arriving at 0, and confirming that Refl witnesses 0 = 0.
let
H = fx.types.hoas;
inherit (H) nat eq zero refl checkHoas;
# Addition by structural recursion on the first argument
add = m: n:
H.ind (H.lam "_" nat (_: nat)) n
(H.lam "k" nat (_: H.lam "ih" nat (ih: H.succ ih))) m;
in
# Prove: 0 + 0 = 0
(checkHoas (eq nat (add zero zero) zero) refl).tag == "refl"
# → true
checkHoas is the kernel's entry point. It takes a type and a term,
runs bidirectional type checking with normalization by evaluation, and
returns a result. If the result's tag is "refl", the proof was
accepted. If it has an error field, the kernel rejected it.
The kernel doesn't pattern-match on 0 + 0 = 0 as a special case. It
evaluates add(zero, zero) by running the NatElim eliminator — the
base case fires, returns n (which is zero), and the kernel sees
Eq(Nat, zero, zero). Refl witnesses any Eq(A, x, x), so the
proof goes through.
Larger numbers work the same way. The kernel unrolls the recursion:
# 3 + 5 = 8
(checkHoas (eq nat (add (H.natLit 3) (H.natLit 5)) (H.natLit 8)) refl).tag == "refl"
# 10 + 7 = 17
(checkHoas (eq nat (add (H.natLit 10) (H.natLit 7)) (H.natLit 17)) refl).tag == "refl"
Both reduce to true. The kernel normalizes add(3, 5) step by
step — three succ peels, then the base case returns 5, then three
succ wrappers are reapplied — and confirms the result is 8.
Dependent witnesses
A computational equality says "these two things are the same." A
dependent witness says "here is a value, and here is evidence that
it has a property." The Sigma type Σ(x:A).P(x) packages both: a
value x of type A, and a proof that P(x) holds.
let
H = fx.types.hoas;
inherit (H) nat eq sigma zero pair refl checkHoas;
in {
# "There exists x : Nat such that x = 0" — witnessed by (0, Refl)
witness = let
ty = sigma "x" nat (x: eq nat x zero);
tm = pair zero refl ty;
in (checkHoas ty tm).tag == "pair";
# → true
}
The type Σ(x:Nat). Eq(Nat, x, 0) says "a natural number equal to
zero." The term (0, Refl) inhabits it: 0 for the value, Refl for
the proof that 0 = 0. The kernel checks both components — it
confirms 0 : Nat and Refl : Eq(Nat, 0, 0).
Witnesses get more interesting when the property involves computation:
# "There exists x such that 3+5 = x" — witnessed by (8, Refl)
witnessAdd = let
add = m: n:
H.ind (H.lam "_" nat (_: nat)) n
(H.lam "k" nat (_: H.lam "ih" nat (ih: H.succ ih))) m;
ty = sigma "x" nat (x: eq nat (add (H.natLit 3) (H.natLit 5)) x);
tm = pair (H.natLit 8) refl ty;
in (checkHoas ty tm).tag == "pair";
The kernel normalizes add(3, 5) to 8, checks that 8 matches the
witness value, and accepts the proof. If you claimed the witness was
7, the kernel would reject it — Refl can't witness 8 = 7.
Eliminators
Eliminators are how you compute over inductive types in type theory.
Where Nix uses if/else and list folds, the kernel uses eliminators:
structured recursion with a motive that declares what type the result
has. The motive is what makes these dependently typed — the return type
can vary based on the input.
Booleans
BoolElim(motive, trueCase, falseCase, scrutinee) — case analysis on
a boolean. With a constant motive (return type doesn't depend on the
boolean), it's equivalent to an if/else:
let
H = fx.types.hoas;
inherit (H) nat bool eq zero refl boolElim checkHoas;
in {
# if true then 42 else 0 = 42
trueCase = let
result = boolElim (H.lam "_" bool (_: nat)) (H.natLit 42) zero H.true_;
in (checkHoas (eq nat result (H.natLit 42)) refl).tag == "refl";
# if false then 42 else 0 = 0
falseCase = let
result = boolElim (H.lam "_" bool (_: nat)) (H.natLit 42) zero H.false_;
in (checkHoas (eq nat result zero) refl).tag == "refl";
}
Natural numbers
NatElim(motive, base, step, n) — structural recursion. The base
case handles zero, the step case takes the predecessor k and the
inductive hypothesis ih (the result for k) and produces the result
for S(k):
let
H = fx.types.hoas;
inherit (H) nat eq refl checkHoas;
# double(n): double(0) = 0, double(S(k)) = S(S(double(k)))
double = n: H.ind (H.lam "_" nat (_: nat)) H.zero
(H.lam "k" nat (_: H.lam "ih" nat (ih: H.succ (H.succ ih)))) n;
in
# double(4) = 8
(checkHoas (eq nat (double (H.natLit 4)) (H.natLit 8)) refl).tag == "refl"
The kernel unrolls four steps: double(4) = S(S(double(3))) = ... = 8.
Lists
ListElim(elemType, motive, nilCase, consCase, list) — structural
recursion on lists. The nil case provides the base value, the cons case
takes the head, tail, and inductive hypothesis:
let
H = fx.types.hoas;
inherit (H) nat eq refl checkHoas;
list123 = H.cons nat (H.natLit 1) (H.cons nat (H.natLit 2)
(H.cons nat (H.natLit 3) (H.nil nat)));
# sum(xs): fold with addition
sumList = xs: H.listElim nat (H.lam "_" (H.listOf nat) (_: nat)) H.zero
(H.lam "h" nat (h: H.lam "t" (H.listOf nat) (_:
H.lam "ih" nat (ih:
H.ind (H.lam "_" nat (_: nat)) ih
(H.lam "k" nat (_: H.lam "ih2" nat (ih2: H.succ ih2))) h)))) xs;
in
# sum([1, 2, 3]) = 6
(checkHoas (eq nat (sumList list123) (H.natLit 6)) refl).tag == "refl"
Sums (coproducts)
SumElim(L, R, motive, leftCase, rightCase, scrutinee) — case
analysis on Left(a) or Right(b):
let
H = fx.types.hoas;
inherit (H) nat bool sum eq zero refl checkHoas;
in {
# case Left(5) of { Left n → n; Right _ → 0 } = 5
leftCase = let
scrut = H.inl nat bool (H.natLit 5);
result = H.sumElim nat bool (H.lam "_" (sum nat bool) (_: nat))
(H.lam "n" nat (n: n))
(H.lam "b" bool (_: zero))
scrut;
in (checkHoas (eq nat result (H.natLit 5)) refl).tag == "refl";
}
The J eliminator
Everything above uses Refl on equalities that the kernel verifies
by computation — normalize both sides, confirm they match. But what if
you want to reason about equalities? Prove that equality is
symmetric, or that applying a function to equal inputs gives equal
outputs? That requires the J eliminator, the fundamental proof
principle for identity types in Martin-Löf type theory [1].
J says: if you can prove something about x = x (the reflexive case),
you can prove it about any x = y where the equality is witnessed.
J(A, a, P, pr, b, eq)
A : type
a : left side of the equality
P : λ(y:A). λ(_:Eq(A,a,y)). Type — the motive
pr : P(a, refl) — the base case (when y = a)
b : right side
eq : Eq(A, a, b) — proof that a = b
Returns: P(b, eq)
Computation rule: J(A, a, P, pr, a, refl) = pr
When the equality proof is Refl, J returns the base case directly.
The kernel reduces J(..., refl) to pr, and the proof goes through.
Congruence
If x = y, then f(x) = f(y) for any function f. This is the
standard cong combinator, derived from J:
let
H = fx.types.hoas;
inherit (H) nat eq u forall refl checkHoas;
congType =
forall "A" (u 0) (a:
forall "B" (u 0) (b:
forall "f" (forall "_" a (_: b)) (f:
forall "x" a (x:
forall "y" a (y:
forall "_" (eq a x y) (_:
eq b (H.app f x) (H.app f y)))))));
congTerm = H.lam "A" (u 0) (a:
H.lam "B" (u 0) (b:
H.lam "f" (forall "_" a (_: b)) (f:
H.lam "x" a (x:
H.lam "y" a (y:
H.lam "p" (eq a x y) (p:
H.j a x
(H.lam "y'" a (y':
H.lam "_" (eq a x y') (_:
eq b (H.app f x) (H.app f y'))))
refl y p))))));
in
(checkHoas congType congTerm).tag == "lam"
The derivation: J eliminates the proof p : Eq(A, x, y). The motive
says "given y' equal to x, produce Eq(B, f(x), f(y'))." In the
base case, y' = x, so the goal is Eq(B, f(x), f(x)) — which
Refl proves. J then transports this to Eq(B, f(x), f(y)).
This generic combinator type-checks with abstract variables A, B,
f, x, y — the kernel verifies the reasoning is valid for all
inputs. On concrete data, J receives Refl (since concrete equalities
reduce by computation), and the kernel simplifies:
# Concrete: from add(2,1) = 3, derive succ(add(2,1)) = succ(3)
congConcrete = let
add21 = add (H.natLit 2) (H.succ H.zero);
three = H.natLit 3;
in (checkHoas
(eq nat (H.succ add21) (H.succ three))
(H.j nat add21
(H.lam "y" nat (y:
H.lam "_" (eq nat add21 y) (_:
eq nat (H.succ add21) (H.succ y))))
refl three refl)).tag == "j";
Symmetry
If x = y, then y = x. The motive is λy'.λ_. Eq(A, y', x) —
when y' = x, the goal is Eq(A, x, x), proved by Refl:
# sym : Π(A:U₀). Π(x:A). Π(y:A). Eq(A,x,y) → Eq(A,y,x)
symTerm = H.lam "A" (u 0) (a:
H.lam "x" a (x:
H.lam "y" a (y:
H.lam "p" (eq a x y) (p:
H.j a x
(H.lam "y'" a (y': H.lam "_" (eq a x y') (_: eq a y' x)))
refl y p))));
Transitivity
If x = y and y = z, then x = z. Fix p : Eq(A, x, y), then
eliminate q with J. The motive is λz'.λ_. Eq(A, x, z') — when
z' = y, the goal is Eq(A, x, y), proved by p:
# trans : Π(A:U₀). Π(x:A). Π(y:A). Π(z:A). Eq(A,x,y) → Eq(A,y,z) → Eq(A,x,z)
transTerm = H.lam "A" (u 0) (a:
H.lam "x" a (x:
H.lam "y" a (y:
H.lam "z" a (z:
H.lam "p" (eq a x y) (p:
H.lam "q" (eq a y z) (q:
H.j a y
(H.lam "z'" a (z': H.lam "_" (eq a y z') (_: eq a x z')))
p z q))))));
Transport
The most general form. If x = y and P(x) holds, then P(y) holds.
Congruence, symmetry, and transitivity are all special cases.
# transport : Π(A:U₀). Π(P:A→U₀). Π(x:A). Π(y:A). Eq(A,x,y) → P(x) → P(y)
transportTerm = H.lam "A" (u 0) (a:
H.lam "P" (forall "_" a (_: u 0)) (bigP:
H.lam "x" a (x:
H.lam "y" a (y:
H.lam "p" (eq a x y) (p:
H.lam "px" (H.app bigP x) (px:
H.j a x
(H.lam "y'" a (y': H.lam "_" (eq a x y') (_: H.app bigP y')))
px y p))))));
Chaining proofs
J applications compose. Here we chain congruence (lift through succ)
with symmetry (reverse the equality) — two J applications, the output
of the first feeding as the equality proof to the second:
# From Eq(Nat, add(2,1), 3):
# Step 1 (cong succ): Eq(Nat, S(add(2,1)), S(3))
# Step 2 (sym): Eq(Nat, S(3), S(add(2,1)))
combinedProof = let
add21 = add (H.natLit 2) (H.succ H.zero);
three = H.natLit 3;
sadd21 = H.succ add21;
sthree = H.succ three;
# Step 1: cong succ
congStep = H.j nat add21
(H.lam "y" nat (y: H.lam "_" (eq nat add21 y) (_: eq nat sadd21 (H.succ y))))
refl three refl;
# Step 2: sym on the cong result
in (checkHoas (eq nat sthree sadd21)
(H.j nat sadd21
(H.lam "y" nat (y: H.lam "_" (eq nat sadd21 y) (_: eq nat y sadd21)))
refl sthree congStep)).tag == "j";
Verified extraction
Proofs establish that properties hold. Verified extraction goes
further: write an implementation in HOAS, the kernel type-checks it
against a specification, and v.verify extracts a callable Nix
function. The result is an ordinary Nix value — an integer, a boolean,
a function, a list — but one whose implementation was machine-checked
before use.
The simplest case
let
H = fx.types.hoas;
v = fx.types.verified;
# Kernel-verified successor: Nat → Nat
succFn = v.verify (H.forall "x" H.nat (_: H.nat))
(v.fn "x" H.nat (x: H.succ x));
in
succFn 5 # → 6
v.verify does three things: elaborates the HOAS into kernel terms,
type-checks the implementation against the type, and extracts the
result as a Nix value. The extracted function is plain Nix — no kernel
overhead at call time.
v.fn is a convenience wrapper around H.lam that threads the
extraction metadata. You could write raw H.lam instead, but v.fn
handles the plumbing for multi-argument functions and pattern matching.
Pattern matching
v.match builds a NatElim with a constant motive. You provide the
result type, the scrutinee, and branches for zero and succ:
# Verified addition: Nat → Nat → Nat
addFn = 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;
})));
addFn 2 3 # → 5
addFn 0 7 # → 7
The succ branch receives two arguments: k (the predecessor) and
ih (the inductive hypothesis — the result for k). For addition,
ih is add(k, n), so wrapping it with succ gives add(S(k), n).
Boolean and cross-type elimination
v.if_ builds a BoolElim for verified booleans:
# Verified not: Bool → Bool
notFn = v.verify (H.forall "b" H.bool (_: H.bool))
(v.fn "b" H.bool (b:
v.if_ H.bool b { then_ = v.false_; else_ = v.true_; }));
notFn true # → false
notFn false # → true
Cross-type elimination — scrutinize one type, return another — works by specifying a different result type:
# Verified isZero: Nat → Bool
isZeroFn = v.verify (H.forall "n" H.nat (_: H.bool))
(v.fn "n" H.nat (n:
v.match H.bool n {
zero = v.true_;
succ = _k: _ih: v.false_;
}));
isZeroFn 0 # → true
isZeroFn 5 # → false
List operations
v.map, v.filter, and v.fold are verified list combinators. Each
takes HOAS terms, not Nix functions — the kernel verifies the entire
pipeline:
# Composed pipeline: filter zeros, then sum
# Input: [0, 3, 0, 2, 1] → Filter: [3, 2, 1] → Sum: 6
composedResult = let
input = H.cons H.nat (v.nat 0) (H.cons H.nat (v.nat 3)
(H.cons H.nat (v.nat 0) (H.cons H.nat (v.nat 2)
(H.cons H.nat (v.nat 1) (H.nil H.nat)))));
nonZero = v.fn "n" H.nat (n:
v.match H.bool n {
zero = v.false_;
succ = _k: _ih: v.true_;
});
addCombine = v.fn "a" H.nat (a: v.fn "acc" H.nat (acc:
v.match H.nat a {
zero = acc;
succ = _k: ih: H.succ ih;
}));
in v.verify H.nat (v.fold H.nat H.nat (v.nat 0) addCombine
(v.filter H.nat nonZero input));
# → 6
The kernel verifies the filter predicate (Nat → Bool), the fold
combinator (Nat → Nat → Nat), and their composition before extracting
the result. A type error in any component — say, returning a Nat
where the filter expects a Bool — fails at nix eval, not at runtime.
Records and string operations
The kernel supports record types (elaborated as nested Sigma) and
string equality (strEq is a kernel primitive). Together they handle
verified functions over structured data with string fields:
let
H = fx.types.hoas;
v = fx.types.verified;
RecTy = H.record [
{ name = "name"; type = H.string; }
{ name = "target"; type = H.string; }
];
# Verified: does the name match the target?
matchFn = v.verify (H.forall "r" RecTy (_: H.bool))
(v.fn "r" RecTy (r:
v.strEq (v.field RecTy "name" r) (v.field RecTy "target" r)));
in {
yes = matchFn { name = "hello"; target = "hello"; }; # → true
no = matchFn { name = "hello"; target = "world"; }; # → false
}
v.field desugars to the right chain of fst/snd projections for
the field's position in the Sigma chain. v.strEq reduces in the
kernel via the StrEq primitive — it compares string literals during
normalization, producing true or false as kernel values.
Proof-gated derivations
The examples so far verify individual functions or prove individual
equalities. The
typed-derivation.nix
example combines them into a proof-gated service builder. To be clear:
this example is contrived. You could enforce the same policy with a few
assert statements. The point is that the kernel normalizes and checks
it as a proof obligation rather than a runtime boolean. The interesting
direction is dependent NixOS module types where the type of one option
depends on the value of another; that isn't working yet.
The architecture has three layers:
Tier 2 — Verified computation. The kernel computes the effective
protocol. Public-facing services (bind = "0.0.0.0") are forced to
HTTPS regardless of what the config says. The extracted function always
returns the correct protocol because the kernel proved it total.
effectiveProtocol = v.verify (H.forall "s" ServiceConfig (_: H.string))
(v.fn "s" ServiceConfig (s:
v.if_ H.string (v.strEq (v.field ServiceConfig "bind" s)
(v.str "0.0.0.0")) {
then_ = v.str "https";
else_ = v.field ServiceConfig "protocol" s;
}));
Tier 3 — Machine-checked proof. A policy function checks five constraints: bind address, protocol, log level, and port in allowed sets, plus the cross-field invariant (public bind requires HTTPS). For each concrete config, the builder:
- Encodes the Nix attrset as a kernel record (
elaborateValue) - Applies the policy function to it
- Normalizes the result via NbE
- Checks
Refl : Eq(Bool, result, true)
If the policy evaluates to true, Refl witnesses true = true and
the service builds. If it evaluates to false, the kernel rejects
Refl : Eq(Bool, false, true) and the build aborts at eval time.
mkVerifiedService = cfg: let
cfgHoas = elaborateValue ServiceConfig cfg;
applied = H.app policyAnn cfgHoas;
proofTy = H.eq H.bool applied H.true_;
checked = H.checkHoas proofTy H.refl;
protocol = effectiveProtocol cfg;
in if checked ? error
then throw "Proof rejected: ..."
else pkgs.writeShellScriptBin cfg.name ''
echo "${cfg.name} listening on ${protocol}://${cfg.bind}:${cfg.port}"
...
'';
The localhost HTTP server passes — all policy constraints hold, the
kernel accepts the proof, and nix build produces a runnable binary:
api-server = mkVerifiedService {
name = "api-server"; bind = "127.0.0.1";
port = "8080"; protocol = "http"; logLevel = "info";
};
The public HTTP server is rejected — bind = "0.0.0.0" with
protocol = "http" violates the cross-field invariant. The kernel
normalizes policy(cfg) to false, and Refl : Eq(Bool, false, true)
is ill-typed:
insecure-public = mkVerifiedService {
name = "insecure-public"; bind = "0.0.0.0";
port = "80"; protocol = "http"; logLevel = "debug";
};
# error: Proof rejected: service 'insecure-public' violates policy
This is a genuine machine-checked proof, not a runtime assertion. The
kernel's type checker — the same one that verifies 3 + 5 = 8 — does
the work. The proof obligation Eq(Bool, policy(cfg), true) is checked
by the same bidirectional algorithm that handles Pi types, Sigma types,
and J elimination. The only difference is that the "theorem" here is
about a service config rather than a natural number.
What the kernel can and cannot prove
The nix-effects kernel implements Martin-Löf type theory with universes, dependent functions, dependent pairs, identity types, natural numbers, booleans, lists, sums, unit, void, and eleven axiomatized Nix primitives. It can prove any property that reduces to a comparison of normal forms.
It can prove:
- Equalities between computed values:
add(3, 5) = 8,length([1,2,3]) = 3,append([1,2], [3]) = [1,2,3] - Properties of concrete data: "this config field is in the allowed set," "this port number is valid," "these two strings match"
- Generic combinators:
cong,sym,trans, andtransporttype-check with abstract variables - Verified function extraction: any function expressible with the kernel's eliminators can be verified and extracted
- Cross-field invariants: "if bind is public, protocol must be HTTPS"
It cannot prove:
Symbolic induction.
forall n, n + 0 = nrequires induction over an abstract variable. TheNatElimevaluator only reduces on concrete values (VZero,VSucc), not symbolic ones. You can prove3 + 0 = 3and100 + 0 = 100, but not the universal statement. The evaluator would need to produce symbolic normal forms, which is a fundamentally different normalization strategy.Properties of Nix builtins. The kernel axiomatizes
String,Int,Float, etc. as opaque types.builtins.stringLengthis not a kernel function — the kernel hasstrEqfor string comparison but no string operations beyond equality and list membership.Eta-expansion. The kernel does not identify
fwithλx.f(x). Functions that are extensionally equal but intensionally different are not convertible.User-defined recursive types. The kernel has inductive types (Nat, List, Bool, Sum) built in as eliminators but does not support user-defined inductive families. You cannot define a binary tree or a red-black tree in the kernel.
For Nix, the "concrete data" restriction is less of a limitation than it sounds. Nix evaluates configurations completely before building — every module option, every service config, every package attribute is a concrete value at eval time. The kernel verifies all computable properties of that concrete data. What it gives up is proving things about all possible configurations generically. In practice, you prove properties of the specific configuration being built, which is the one that matters.
Quick reference
| Pattern | Type | Proof term |
|---|---|---|
| Computational equality |
Eq(A, x, y) where x, y normalize to same value |
Refl |
| Dependent witness |
Σ(x:A). P(x) |
(value, proof) |
| Case analysis (bool) |
BoolElim(motive, true_case, false_case, b) | Result of elimination |
| Structural recursion (nat) |
NatElim(motive, base, step, n) | Result of elimination |
| List recursion |
ListElim(elem, motive, nil_case, cons_case, xs) | Result of elimination |
| Sum dispatch |
SumElim(L, R, motive, left_case, right_case, s) | Result of elimination |
| Congruence |
Eq(A,x,y) → Eq(B, f(x), f(y)) |
J(A, x, λy'.λ_. Eq(B,f(x),f(y')), Refl, y, p) |
| Symmetry |
Eq(A,x,y) → Eq(A,y,x) |
J(A, x, λy'.λ_. Eq(A,y',x), Refl, y, p) |
| Transitivity |
Eq(A,x,y) → Eq(A,y,z) → Eq(A,x,z) |
J(A, y, λz'.λ_. Eq(A,x,z'), p, z, q) |
| Transport |
Eq(A,x,y) → P(x) → P(y) |
J(A, x, λy'.λ_. P(y'), px, y, p) |
| Ex falso |
Void → A |
absurd(A, x) |
| Verified function |
v.verify type impl | Extracted Nix function |
| Proof-gated build |
Eq(Bool, policy(cfg), true) |
Refl (kernel normalizes policy) |
References
Martin-Löf, P. (1984). Intuitionistic Type Theory. Bibliopolis.
The Univalent Foundations Program (2013). Homotopy Type Theory: Univalent Foundations of Mathematics. Institute for Advanced Study. [pdf]
Norell, U. (2007). Towards a practical programming language based on dependent type theory. PhD thesis, Chalmers. [pdf]