Nix language: values and attrsets
Nix language: values and attrsets
Goal: Read and write Nix values, attrsets, let…in, and inherit well enough to model config-shaped data and follow nixpkgs snippets.
This chapter is language only—no flakes, no NixOS rebuilds. Everything runs in nix repl or nix eval.
Why this chapter matters
NixOS configuration is nested attribute sets with types and merge rules layered on top. If attrsets feel alien, every later chapter becomes copy-paste. Master the data model once; modules will make sense.
Ops motivation: reading an error about a missing attribute, writing a host model, and reviewing a teammate’s module all require fluent set/list/string literacy—not memorizing every option.
Theory 1 — Values are few; composition is everything
Nix has a small value inventory:
| Kind | Examples | Notes |
|---|---|---|
| Integer | 1, -3 |
Arbitrary precision integers |
| Float | 1.5 |
Prefer integers unless you need floats |
| Boolean | true, false |
|
| String | "hello", ''multi'' |
Two quoting styles |
| Path | ./foo.nix, /etc/hosts |
Distinct from strings |
| Null | null |
Often “unset” in APIs |
| List | [ 1 2 3 ] |
Heterogeneous allowed; spaces separate elements |
| Attrset | { a = 1; b = "x"; } |
The backbone |
| Function | x: x + 1 |
the language functions chapter |
Type inspection
builtins.typeOf 1 # "int"
builtins.typeOf "x" # "string"
builtins.typeOf ./foo # "path"
builtins.typeOf { a = 1; } # "set"
builtins.typeOf [ 1 2 ] # "list"
builtins.typeOf (x: x) # "lambda"Punchline: almost all “config” is attrsets of attrsets, with lists of packages/strings hanging off leaves.
Theory 2 — Strings: interpolation and two quote forms
Double quotes
let name = "yarara"; in "NixOS ${name}"
# → "NixOS yarara"${…} interpolates an expression that stringifies cleanly (strings, paths, numbers, …).
Indented strings ('' … '')
''
line one
line two
''Common for shell scripts and multi-line text. Leading indentation is stripped relative to the least-indented line.
Escaping notes
| Need | Approach |
|---|---|
Literal ${ in '' strings |
''${ |
| Nested quotes | Prefer indented strings for scripts |
| Path into string | "${./file}" copies/context rules apply carefully |
Paths vs path-looking strings
./config.nix # path — can be imported, copied into store
"./config.nix" # string — just charactersImport expects a path (or something that resolves to one). Confusing the two is a classic beginner bug.
Theory 3 — Lists
[ "git" "vim" "curl" ]
[ 1 2 (3 + 4) ]- No commas between elements
- Parenthesize subexpressions when needed:
[ (f x) y ]
- Concatenate with
++:[ 1 2 ] ++ [ 3 ]
Lists are ordered. Attrsets are keyed. Use the right shape:
| Need | Prefer |
|---|---|
Ordered packages on PATH |
list |
| Named options / config tree | attrset |
| Set of users by name | attrset of attrsets |
List builtins you will reuse
builtins.length [ 1 2 3 ]
builtins.head [ 1 2 3 ]
builtins.tail [ 1 2 3 ]
builtins.elemAt [ 10 20 30 ] 1
builtins.concatLists [ [ 1 ] [ 2 3 ] ]
builtins.filter (x: x > 1) [ 0 1 2 ]
builtins.map (x: x * 2) [ 1 2 3 ]Theory 4 — Attribute sets (the main event)
{
hostName = "lab";
enable = true;
ports = [ 22 80 ];
}Access
let cfg = { hostName = "lab"; enable = true; };
in cfg.hostName
# or
cfg.${"hostName"}Nested sets
{
networking = {
hostName = "lab";
firewall = {
enable = true;
allowedTCPPorts = [ 22 ];
};
};
}This is the shape of NixOS config, even before the module system:
config
└─ networking
├─ hostName
└─ firewall
├─ enable
└─ allowedTCPPorts
Dynamic keys
let key = "hostName";
in { ${key} = "lab"; }Quoted keys for unusual names: { "my-key" = 1; }.
Update / merge intuition
# shallow “override” via //
{ a = 1; b = 2; } // { b = 9; c = 3; }
# → { a = 1; b = 9; c = 3; }// is shallow. Nested sets are replaced wholesale unless you merge carefully (later: lib.recursiveUpdate, module merge).
{ n = { x = 1; }; } // { n = { y = 2; }; }
# → { n = { y = 2; }; } # x is gone!Attrset builtins
builtins.attrNames { z = 1; a = 2; }
builtins.attrValues { a = 10; b = 20; }
builtins.hasAttr "a" { a = 1; }
builtins.getAttr "a" { a = 1; }
builtins.removeAttrs { a = 1; b = 2; } [ "a" ]Theory 5 — let … in (local bindings)
let
a = 1;
b = a + 2;
in
a + b
# → 4Rules that matter
- Bindings in one
letcan refer to each other (order-independent among pure values).
- Scope ends at the
inexpression.
- Prefer
letover repeating huge expressions.
- Infinite recursion is possible if a binding depends on itself without a fixed point.
let x = x + 1; in x # boom: infinite recursionNested lets vs one flat let
Either works; flat is often clearer for small configs:
let
host = "lab";
user = "alice";
packages = [ "git" "vim" ];
in {
networking.hostName = host;
users.users.${user}.isNormalUser = true;
environment.systemPackages = packages; # shape only — not real pkgs yet
}rec attrsets
rec {
a = 1;
b = a + 1; # refers to a in the same set
}Useful; easy to create cycles. Prefer let when clarity wins.
Theory 6 — inherit (pull names into sets or lets)
Into an attrset
let
pkgs = { hello = "h"; git = "g"; };
system = "x86_64-linux";
in {
inherit pkgs system;
# equivalent to: pkgs = pkgs; system = system;
}Rename form
let hello = "world"; in { inherit ( { x = hello; } ) x; }Common real form:
{ inherit (pkgs) git vim curl; }
# → { git = pkgs.git; vim = pkgs.vim; curl = pkgs.curl; }Into let
let
inherit (builtins) typeOf attrNames;
in
typeOf { a = 1; }When to inherit: the name in the outer scope is the name you want. When names differ, write the binding explicitly.
Theory 7 — with (know it; use sparingly)
with builtins; typeOf [ 1 2 ]with set; expr injects set’s attributes into scope. Powerful and easy to shadow silently.
let
lib = { id = x: x; };
id = "oops";
in
with lib; id # which id?House rule for this book: prefer let inherit (lib) … or qualified lib.foo over broad with lib;. You will still read with in older code.
Theory 8 — Conditionals and operators (minimal set)
if true then "a" else "b"
true && false
true || false
!true
1 == 1
1 != 2
# ordering on numbers/strings exists; avoid over-clevernessassert cond; value fails evaluation if cond is false—useful later for module assertions; try once in this chapter:
let x = 2; in assert x > 0; xTheory 9 — Config-shaped modeling (bridge to NixOS)
You are not writing a full module yet. You are practicing the data shape:
let
hostName = "lab-vm";
admin = "alice";
sshPort = 22;
in {
networking = {
inherit hostName;
firewall = {
enable = true;
allowedTCPPorts = [ sshPort ];
};
};
users.users.${admin} = {
isNormalUser = true;
extraGroups = [ "wheel" ];
};
environment.systemPackages = [
"git" # stand-ins; real pkgs are derivations later
"vim"
];
}Notice:
- Nested attrsets mirror option paths
${admin}dynamic key for username
inherit hostNameavoids repetition
- Lists for multi-valued options
Theory 10 — Destructuring patterns you will see
let
set = { a = 1; b = 2; };
inherit (set) a b;
in
a + b# nested access sugar people write in modules
config.networking.hostNameReading real nixpkgs becomes pattern matching on these shapes—not learning a second language.
Worked examples bank
Example A — REPL tour
nix repl1 + 1
{ a = 1; b = { c = 2; }; }.b.c
let x = "nix"; in "hello ${x}"
[ 1 2 ] ++ [ 3 ]
{ a = 1; } // { b = 2; }
builtins.attrNames { z = 1; a = 2; }Example B — Deep access and missing attrs
let
cfg = { networking.firewall.enable = true; };
in
cfg.networking.firewall.enableMissing attribute → evaluation error. That is good: fail early.
Example C — inherit package list pattern
let
tools = {
git = "git-placeholder";
vim = "vim-placeholder";
curl = "curl-placeholder";
};
in {
environment.systemPackages = with tools; [ git vim curl ];
# or better without with:
# environment.systemPackages = [ tools.git tools.vim tools.curl ];
}Example D — Shallow merge trap
let
base = { services.foo = { enable = true; port = 80; }; };
overlay = { services.foo = { port = 8080; }; };
in
base // overlay
# services.foo.enable disappeared — shallow //Journal this; module system merges differently later (host modules chapters).
Example E — Dynamic user keys
let
mkUser = name: {
users.users.${name} = {
isNormalUser = true;
};
};
in
mkUser "alice"(Functions deepen in the next chapter; the attrset shape is the point here.)
Lab 1 — Workspace and file evaluation
mkdir -p ~/lab/nixos-book/concepts/attrsets
cd ~/lab/nixos-book/concepts/attrsetsCreate host-model.nix:
let
hostName = "lab";
adminUser = "alice";
packages = [ "git" "vim" "htop" ];
in {
networking = {
inherit hostName;
firewall.enable = true;
firewall.allowedTCPPorts = [ 22 ];
};
users.users.${adminUser} = {
isNormalUser = true;
extraGroups = [ "wheel" ];
};
environment.systemPackages = packages;
}Evaluate:
nix eval --file ./host-model.nix
nix eval --file ./host-model.nix --apply 'cfg: cfg.networking.hostName'Lab 2 — Nested merge experiments
In merge-trap.nix, reproduce Example D. Also try nested manual merge:
let
base = { services.foo = { enable = true; port = 80; }; };
overlay = { services.foo = { port = 8080; }; };
deep =
base
// {
services = base.services // {
foo = base.services.foo // overlay.services.foo;
};
};
in
deepConfirm enable survives. Write one sentence on why // alone is not “config merge.”
Lab 3 — Attrset utilities in the REPL
builtins.attrNames { c = 1; a = 2; b = 3; }
builtins.attrValues { a = 10; b = 20; }
builtins.hasAttr "a" { a = 1; }
builtins.getAttr "a" { a = 1; }Build a tiny “user directory”:
let
users = {
alice = { uid = 1000; shell = "bash"; };
bob = { uid = 1001; shell = "zsh"; };
};
in {
names = builtins.attrNames users;
aliceShell = users.alice.shell;
}Lab 4 — Model something real from your life
Expand host-model.nix (or a second file) to include fictional but realistic keys for:
- Hostname + domain
- Two users (one admin)
- Three “packages” as strings
- Firewall ports for SSH + one app
- A
services.myapp = { enable = true; … }nest
Use let + inherit at least twice. No need for real NixOS options names to validate—accuracy of shape matters.
Lab 5 — Teach-back
Without looking up, write:
- Difference between path and string
- Why lists use spaces not commas
- What
//does to nested sets
- When you would use
inherit
- Why broad
withis risky
Exercises
- Write attrsets for: web service config, user map, package list—choose list vs set deliberately.
- Convert a JSON-like mental model of your laptop hostname/firewall into Nix.
- Break evaluation with a missing attribute; fix with a default in
let.
- Demonstrate
recvsletfor the same binding graph.
- Use indented strings to draft a tiny shell script body (string only).
- Build a deep nest 4 levels deep; access the leaf two ways.
- Implement shallow vs manual deep merge; document the difference.
- Prefer
inherit (builtins) …in a file and evaluate it.
- Find three places
withwould hurt readability; rewrite without it.
- Model two hosts in one attrset keyed by hostname.
- Use
assertto enforcesshPort > 0.
- Read a random small snippet from nixpkgs (online) and label each value kind.
- Explain why NixOS options look like nested attrsets even though types exist.
- Commit your
~/lab/nixos-book/concepts/attrsetsexperiments.
Common pitfalls
| Symptom | Theory |
|---|---|
error: undefined variable |
Scope / missing let binding |
unexpected ',' in list |
No commas in Nix lists |
| Path vs string import confusion | Paths are a distinct type |
Nested keys “disappeared” after // |
Shallow merge |
with silently shadows |
Prefer explicit / inherit |
Dynamic key without ${} |
Use { ${name} = …; } |
Infinite recursion in let |
Self-reference without base |
| Semicolon missing in attrset | Every binding ends with ; |
| Treating lists as sets | Wrong shape for named config |
Checkpoint
- Named all major value kinds
- Wrote nested attrset for host-shaped config
- Used
let…inandinheritdeliberately
- Demonstrated shallow
//pitfall
- Evaluated a
.nixfile withnix eval
- Personal notes include ≥3 gotchas
- Can choose list vs attrset for a new field
Further depth (this book)
| Topic | Chapter |
|---|---|
| Functions over these values | Nix language: functions |
| Multi-file + lib helpers | Laziness, imports, and lib |
Module merge (not //) |
Host: Modules as a consumer |
| Real host config trees | Host: Flake-defined hosts |