Why this matters
Nix expressions look like JSON with semicolons. They are not. Lists have no commas, paths are a distinct type from strings, and integer division truncates. Every one of these is a silent bug or a cryptic error in a flake. Ten minutes here saves an hour of --show-trace later.
Concept
All examples run with nix eval --expr '...'. In fish, wrap the expression in single quotes; fish does not expand ${} inside them. Double quotes break: fish tries to expand ${name} itself and errors with Variables cannot be bracketed.
Strings
Double-quoted, with \n escapes and ${} interpolation:
let name = "dave"; in "hi ${name}""hi dave"Interpolation only accepts strings (and paths, and attrsets with an outPath). Numbers must be converted:
"port: ${toString 80}""port: 80"Without toString:
error: cannot coerce an integer to a string: 80The + operator concatenates strings and has the same rule: "port: " + toString 80 works, "1" + 1 does not.
Indented strings
Two single quotes. The common leading whitespace is stripped. Interpolation still works. This is how shell hooks and config files are written:
let port = 80; in ''
listen ${toString port};
''"listen 80;\n"Note the trailing \n: the closing '' on its own line keeps the final newline. To write a literal ${ inside an indented string, escape as ''${.
Numbers
Integers and floats are separate types. Division between integers truncates:
[ (7 / 2) (7.0 / 2) (builtins.typeOf 1) (builtins.typeOf 1.5) ][ 3 3.5 "int" "float" ]1 == 1.0 is true; comparison converts. Division does not.
Booleans and null
true, false, null. Operators: &&, ||, !, and -> (implication, rare). if requires a real boolean. if 1 then ... errors with expected a Boolean but found an integer. There is no truthiness.
Lists
Space-separated, square brackets, any mix of types:
[ "a" 1 true null ][ "a" 1 true null ]Commas are a syntax error:
error: syntax error, unexpected ','The trap for TypeScript people: function application also uses spaces. [ 1 2 3 + 4 ] is a syntax error, and [ f x ] is a two-element list, not a call. Parenthesise expressions inside lists:
[ 1 2 (3 + 4) ][ 1 2 7 ]Concatenate with ++. Index with builtins.elemAt list 1 (zero-based). No list[1] syntax.
Paths versus strings
A bare token containing a / and not in quotes is a path, a separate type:
[ (builtins.typeOf ./foo) (builtins.typeOf "./foo") ][ "path" "string" ]Paths are resolved at parse time relative to the file (or the cwd for --expr). Run from /tmp:
./foo/tmp/fooAppending a string to a path gives a path. Appending two paths joins them literally, which is almost never what you want:
[ (./foo + "bar") (./foo + ./bar) ][ /tmp/foobar /tmp/foo/tmp/bar ]Why the type matters: when a path is interpolated into a string, Nix copies the file into the store and substitutes the store path. A string with the same characters is just text.
echo hi > /tmp/pathdemo.txt
nix eval --impure --expr '"${/tmp/pathdemo.txt}"'"/nix/store/b288mh5w3pr7gzbm38gp62hxk5fa0fj2-pathdemo.txt"--impure is needed because --expr runs in pure mode, which forbids reading arbitrary absolute paths. Inside a flake, src = ./.; copies your project into the store; src = "./."; is a string the builder cannot use.
Paths must contain a slash: ./foo, /etc/hosts. foo alone is a variable. Angle brackets <nixpkgs> are a third form, looked up in NIX_PATH; avoid in flakes.
Comments
# line comment
1 /* block comment */ + 12Try it
Run from /tmp so paths match the output shown.
cd /tmp
nix eval --expr '[ (./foo + "bar") (builtins.typeOf ./foo) ]'[ /tmp/foobar "path" ]Now trigger the coercion error on purpose and read it:
nix eval --expr '"port: ${3000}"'error: cannot coerce an integer to a string: 3000Exercise
Write one expression that evaluates to the string "node 22 pnpm 12" using a let with node = 22; and pnpm = 12; as integers, and an indented string. The result must have no trailing newline.
Solution
let
node = 22;
pnpm = 12;
in
''node ${toString node} pnpm ${toString pnpm}''"node 22 pnpm 12"Keeping the closing '' on the same line avoids the trailing newline. Both numbers need toString.
Trap
Writing [ "nodejs" "pnpm", "biome" ] after a day of TypeScript. The error points at the comma. Worse is [ pkgs.nodejs pkgs.pnpm pkgs.biome ] written as [ pkgs.nodejs, pkgs.pnpm ] inside a 200-line flake: the error position is correct, but you will read past it twice before you see the comma.