A couple days ago I posted about using builtins.genericClosure as a trampoline. The trampoline was never the point. It was the thing that made another project tractable.
nix-effects is a Martin-Löf Type Theory proof checker embedded in pure Nix. About 3,000 lines, no evaluator patches, no external tools. Dependent functions, dependent pairs, identity types with J, cumulative universes. It runs at nix eval time, using the same core algorithms as Mini-TT and the elaboration-zoo: NbE, bidirectional type checking, de Bruijn indices.
git clone https://github.com/kleisli-io/nix-effects
I realize “dependent types in Nix” sounds like a stunt. It’s sound because of Pedrot & Tabareau’s Fire Triangle (POPL 2020): dependent types work in any language without observable effects, and Nix evaluation is pure. It’s feasible because of the trampoline. Neither is sufficient on its own.
What you can prove
Arithmetic, list operations, anything that computes to a normal form:
H = fx.types.hoas;
inherit (H) nat eq checkHoas natLit refl;
# 3 + 5 = 8. The kernel normalizes both sides and checks they agree.
checkHoas (eq nat (add (natLit 3) (natLit 5)) (natLit 8)) refl
The equality-proofs.nix file implements congruence, symmetry, transitivity, and transport via the J eliminator, all type-checked with abstract variables, so they hold universally.
From proofs to Nix functions
A proof checker that only checks proofs is a curiosity. The part I actually care about is v.verify: write a term, type-check it against a spec, and extract a plain Nix function. The proof disappears and what you get back is an ordinary Nix value.
# Verified addition: type-checked, then extracted as a Nix function
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, plain Nix integer
There’s a combinator library (v.fn, v.match, v.if_, v.map, v.filter, v.fold, v.field, v.strEq, v.strElem) so you don’t have to write raw kernel terms. verified-functions.nix has 16 examples from successor through composed filter-then-fold pipelines.
Proof-gated derivations
This is a contrived example, but it should be enough to show the shape of what I’m working toward. typed-derivation.nix defines a service config as a kernel record type and a policy function the kernel evaluates at type-checking time. The builder demands Refl : Eq(Bool, policy(cfg), true) before it constructs a derivation. If the proof fails, nix eval aborts. Nothing is built.
# Builds: localhost HTTP on 8080 passes all policy checks
api-server = mkVerifiedService {
name = "api-server"; bind = "127.0.0.1";
port = "8080"; protocol = "http"; logLevel = "info";
};
# Eval aborts: 0.0.0.0 with HTTP violates the cross-field invariant
insecure-public = mkVerifiedService {
name = "insecure-public"; bind = "0.0.0.0";
port = "80"; protocol = "http"; logLevel = "debug";
};
The policy validates each field against an allowed set and enforces that public binds require HTTPS. A separate verified function forces the effective protocol to HTTPS for public binds by construction. The invalid config never produces a derivation because the kernel normalizes policy(cfg) to false and the proof obligation Refl : Eq(Bool, false, true) gets rejected.
To be absolutely clear: this example is ad-hoc. You could do the same validation with a few assert statements.
Try it:
nix build --impure --expr '
let pkgs = import <nixpkgs> {};
fx = import ./nix-effects { inherit (pkgs) lib; };
in (import ./nix-effects/examples/typed-derivation.nix { inherit fx pkgs; }).api-server'
./result/bin/api-server
What it doesn’t do
It’s fuel-bounded (default 10M steps), so it’s a partial proof checker, not a theorem prover. No user-defined inductive types. No IDE support; errors come from nix eval with blame context, which is workable for small proofs and painful for anything bigger. Nix is untyped, so the HOAS encoding can’t enforce parametricity the way PHOAS does in Coq. Exotic terms fail at type-checking time rather than being ruled out by the host.
For context: Dhall, Nickel, and CUE all require leaving Nix. The NixOS module system can express value-dependent option types (via config references), but doesn’t lend itself to formally reasoning about whether modules compose soundly. nix-effects stays inside pure Nix with the longer-term aim of kernel-checked composition, which is the tradeoff I wanted.
Links
- github.com/kleisli-io/nix-effects
- Full technical write-up on blog.kleisli.io
- Trampolining post for the technique that makes this feasible
If you’re interested in the proof theory, I’d appreciate eyes on the kernel. The implementation is in types/kernel.nix (NbE, bidirectional checking, universes) and types/hoas.nix (elaboration from HOAS to de Bruijn), and there’s a kernel spec that documents the typing rules and evaluation semantics. I want people to find errors. A soundness bug in the kernel means the whole verification story is wrong, and I’d rather know now than after someone builds on it.
The thing I keep going back and forth on is whether the right use case is proof-gated derivations (like the service config example), or something more like dependent module types for NixOS options where the type of one option depends on the value of another. Has anyone run into configuration invariants that assert or mkOption types can’t express?