Nix Expressions and Evaluation
Nix Expressions and Evaluation
Nix does not run a list of statements. It evaluates one pure expression. The boring default is: a file is a single value, the same inputs always yield the same AST, and laziness means unused branches (including throw) never run.
This part uses the Nix 2.35 CLI (nix eval). Flakes come in part 3–4; here the language is enough.
Mental model
Bash mutates the world from top to bottom. Nix does not:
- One expression per file.
2 + 2,{ a = 1; }, and a 400-line package are the same kind of thing. - Deterministic. Same tree, same result. No clock, no
$HOME, no “it depends who ran it.” - Lazy. Defining fifty attributes and asking for
.portdoes not compute the other forty-nine.
Bash: step → mutate → step → mutate → leftover state
Nix: root expression → demand-driven subexpressions → a value
nix eval --expr is a scratchpad. nix eval --file evaluates a file. --json is for machines.
Worked examples
Case 1: Arithmetic is an expression
nix eval --expr '1 + 2 * 3'Output:
7
Precedence is ordinary. There is no print. The value is the result.
Case 2: A file is one let … in
Save as root_expression.nix:
# root_expression.nix
let
deskId = "terminal-01";
status = "online";
in
{
id = deskId;
state = status;
ready = true;
}nix eval --file root_expression.nix --jsonOutput:
{"id":"terminal-01","ready":true,"state":"online"}
--json sorts keys. The file did not “run” three assignments; it produced one attrset.
Case 3: Laziness skips throw
Save as laziness_proof.nix:
# laziness_proof.nix
let
validField = "desk-service";
brokenField = throw "This error is never triggered!";
in
{
service = validField;
broken = brokenField;
}nix eval --file laziness_proof.nix --apply 'x: x.service'Output:
"desk-service"
broken exists on the set. It is not forced. Force it:
nix eval --file laziness_proof.nix --apply 'x: x.broken'Output (shape):
error: This error is never triggered!
That error is the proof: evaluation is demand-driven.
Case 4: --strict / JSON vs thunks
nix eval --expr '{ a = 1 + 1; b = throw "no"; }' --apply 'x: x.a'Output:
2
nix-instantiate --eval without --strict can print thunks as <CODE>. Prefer nix eval (2.35) plus --json or --apply so you see values, not internals.
Case 5: No side effects at eval time
Save as no_echo.nix:
# no_echo.nix
let
# This does not print. It is a string.
msg = "desk online";
in
msgnix eval --file no_echo.nixOutput:
"desk online"
There is no echo during eval. Writes happen later, in a derivation sandbox (fundamentals / packaging). If you wanted a file on disk, you have not evaluated yet — you have not built.
throw "msg" is a lazy error (only if demanded). abort "msg" is immediate. Use throw in unused branches; abort when the file must not even load. Nix 2.35 can drop you into a debugger:
nix eval --file laziness_proof.nix --apply 'x: x.broken' --debugger:backtrace then. Do not leave builtins.trace in production modules after you found the bug.
The trap
The trap is expecting echo or curl inside a .nix file to run when you nix eval. Eval is memory. Network and files belong in FOD fetchers and builders.
A second trap: two expressions in one file with no let. That is a parse error. One root.
The boring rule
- One expression per file.
nix eval --expr/--fileon Nix 2.35.--jsonwhen you want stable output.- Laziness: unused
throwis silent; accessedthrowis an eval error.abortalways fires. --debuggeron 2.35 for eval traces. No leftoverbuiltins.traceon main.- No side effects at eval. Builds write
$out. --applyto pick a field without evaluating the whole set.
Try this
- Case 3: eval
.servicethen.broken. Predict before you run. nix eval --expr 'let x = 1 / 0; in 4'— does it error? (Integer division by zero is forced only ifxis used.)- Put two attrsets in one file without
let. Read the parse error. nix eval --expr '{ a = 1; }' --jsonand compare to--impure— you should not need--impurehere.