Day 3 — Language: values & attrsets
Day 3 — Language: values & attrsets
Stage I · ~4h (theory-heavy)
Goal: Read and write Nix values, attrsets, let…in, and inherit well enough to model config-shaped data and follow nixpkgs snippets.
Today is language only—no flakes, no NixOS rebuilds. Everything runs in nix repl or nix eval.
Why this day exists
NixOS configuration is nested attribute sets with types and merge rules layered on top. If attrsets feel alien, every later day becomes copy-paste. Master the data model once; modules will make sense.
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 |
Day 4 |
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.
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 |
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!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
}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 volume: 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 today:
let x = 2; in assert x > 0; xTheory 9 — Config-shaped modeling (bridge to NixOS)
You are not writing a full module today. 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
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.
Lab 1 — Workspace and file evaluation
mkdir -p ~/lab/90daysofx/02-nixos/day03
cd ~/lab/90daysofx/02-nixos/day03Create 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
Common gotchas
| 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 |
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
Commit
cd ~/lab/90daysofx/02-nixos/day03
git init 2>/dev/null || true
git add .
git commit -m "day03: language values attrsets let inherit"Tomorrow
Day 4 — Language: functions. Pattern arguments { a, b ? d }:, @ args, ellipsis ..., and small helpers you will reuse in flakes.