Day 5 — Laziness, imports & lib
Day 5 — Laziness, imports & lib
Stage I · ~4h (theory-heavy)
Goal: Split expressions across files, understand lazy values vs eager imports, and run one useful lib pipeline without memorizing the whole standard library.
Why this day exists
Real Nix is multi-file. People hit “infinite recursion,” mysteriously slow eval, or “why did that download?” without a model of when work happens. Today installs that model and introduces lib as the toolbox you will touch forever.
Theory 1 — Laziness: values are promises
Nix does not compute a binding until something needs its value.
let
a = builtins.trace "forcing a" (1 + 1);
b = builtins.trace "forcing b" (2 + 2);
in
aOnly "forcing a" prints—b is never forced.
Why it matters
| Benefit | Example |
|---|---|
| Huge package sets | Unused packages not fully evaluated |
| Optional config | Disabled modules can avoid heavy work |
| Recursive structures | Attrsets can refer to each other carefully |
Forcing
Anything that inspects the value forces it: arithmetic, string interpolation of it, builtins.length, taking an attribute that needs it, building a derivation that lists it as input, etc.
let
expensive = builtins.trace "boom" (import ./big.nix);
in
if false then expensive else 0
# "boom" should not appear — branch not takenTheory 2 — Imports are different: loading is eager
import ./other.niximport reads and parses the file and evaluates the root expression of that file when the import expression is forced. People say “imports are eager” meaning: once you import, that file’s top-level expression is evaluated (subject to laziness inside that file’s bindings).
Mental model
import path
→ read file
→ parse
→ evaluate root expression of that file
→ return the resulting value (function, attrset, …)
Common patterns
# other.nix returns an attrset
import ./other.nix
# other.nix returns a function
import ./mkHost.nix { hostName = "lab"; user = "alice"; }
# relative to current file
import ./modules/ssh.nixPaths
Prefer path literals so imports resolve relative to the file, not the process cwd:
import ./lib.nix # good
import "./lib.nix" # string — different rules; avoid casuallyTheory 3 — Multi-file composition patterns
Pattern A — Data module
users.nix:
{
alice = { groups = [ "wheel" ]; };
bob = { groups = [ ]; };
}default.nix:
let
users = import ./users.nix;
in {
names = builtins.attrNames users;
inherit users;
}Pattern B — Function module
mkUser.nix:
{ name, groups ? [ ] }: {
users.users.${name} = {
isNormalUser = true;
extraGroups = groups;
};
}default.nix:
import ./mkUser.nix { name = "alice"; groups = [ "wheel" ]; }Pattern C — Directory + default.nix
Nix allows import ./dir when ./dir/default.nix exists. Useful for tiny libraries.
Theory 4 — Infinite recursion (how you will meet it)
let
x = y;
y = x;
in
xlet
pkgs = import nixpkgs { inherit system overlays; };
overlays = [ (final: prev: { x = pkgs.hello; }) ]; # pkgs not ready
in
pkgsDebugging habits
- Shrink the expression
builtins.trace "here" value
- Break cycles by not referencing the final fixed point too early
- Remember: printing an attrset forces attributes (in some UIs)
Theory 5 — lib as the standard library (taste, not catalog)
When you have nixpkgs:
lib = pkgs.lib;
# or
lib = import <nixpkgs/lib>; # classic; flakes prefer pkgs.libAttrsets
| Function | Role |
|---|---|
lib.mapAttrs |
Map over name/value |
lib.filterAttrs |
Filter by predicate |
lib.attrNames / lib.attrValues |
Keys / values |
lib.recursiveUpdate |
Deep-ish merge |
lib.optionalAttrs |
Conditionally include an attrset |
Lists
| Function | Role |
|---|---|
lib.concatLists |
Flatten one level |
lib.unique |
Dedup |
lib.toList |
Wrap non-list |
lib.optional |
[ x ] or [] from bool |
lib.optionals |
list or [] from bool |
Strings
| Function | Role |
|---|---|
lib.concatStringsSep |
Join |
lib.hasPrefix / hasSuffix |
Tests |
lib.toLower |
Case |
Example pipeline — users list → attrset
{ lib }:
let
userList = [
{ name = "alice"; admin = true; }
{ name = "bob"; admin = false; }
];
in
lib.listToAttrs (
map (u: {
name = u.name;
value = {
isNormalUser = true;
extraGroups = lib.optional u.admin "wheel";
};
}) userList
)Theory 6 — NIX_PATH and angle-bracket paths (literacy)
import <nixpkgs> { }<nixpkgs> looks up NIX_PATH. This volume’s default later is flakes—not channels—but you must recognize angle brackets in docs.
| Style | Pinning |
|---|---|
<nixpkgs> |
Ambient, drift-prone |
import ./pinned.nix |
File-relative |
Flake inputs.nixpkgs |
Lock file (preferred) |
Today’s labs should use relative imports between your files; if you pull nixpkgs, prefer a flake or a known pin over bare <nixpkgs> when you can.
Theory 7 — Evaluation vs runtime (again, file-centric)
let
data = import ./data.nix; # eval time: load nix
# building a package would realize store paths — Day 6
in
dataImporting a Nix file does not compile C. It only produces Nix values (which might describe builds).
Worked examples bank
Example A — Lazy skip
let
boom = builtins.abort "should not run";
in
if true then 1 else boomExample B — Import function
add.nix:
{ a, b }: a + bimport ./add.nix { a = 2; b = 40; }Example C — lib.optional / optionals
lib.optional true "wheel" # [ "wheel" ]
lib.optional false "wheel" # [ ]
lib.optionals true [ "a" "b" ] # [ "a" "b" ]Example D — mapAttrs
lib.mapAttrs (name: cfg: cfg // { inherit name; }) {
alice = { uid = 1000; };
bob = { uid = 1001; };
}Lab 1 — Multi-file project skeleton
mkdir -p ~/lab/90daysofx/02-nixos/day05/{lib,data}
cd ~/lab/90daysofx/02-nixos/day05data/users.nix:
[
{ name = "alice"; admin = true; shell = "bash"; }
{ name = "bob"; admin = false; shell = "zsh"; }
{ name = "carol"; admin = true; shell = "bash"; }
]lib/users.nix:
{ lib }:
userList:
lib.listToAttrs (
map (u: {
name = u.name;
value = {
isNormalUser = true;
extraGroups = lib.optional u.admin "wheel";
# shell left as string stand-in
shellName = u.shell;
};
}) userList
)default.nix (standalone lib stub so you need no nixpkgs for this lab):
let
lib = {
optional = cond: x: if cond then [ x ] else [ ];
listToAttrs = builtins.listToAttrs;
};
userList = import ./data/users.nix;
toUsers = import ./lib/users.nix { inherit lib; };
users = toUsers userList;
in {
inherit users;
admins =
map (u: u.name) (builtins.filter (u: u.admin) userList);
}nix eval --file ./default.nix
nix eval --file ./default.nix --apply 'v: v.admins'Lab 2 — Trace forcing
Create trace.nix:
let
a = builtins.trace "force-a" 1;
b = builtins.trace "force-b" 2;
c = a + 10;
in
cnix eval --file ./trace.nixEdit so the result uses b instead; confirm traces change. Record observations.
Lab 3 — Infinite recursion (safe sandbox)
# cycle.nix — expected to fail
let x = y; y = x; in xnix eval --file ./cycle.nix || trueWrite a one-line description of the error. Then fix a near-cycle by breaking the dependency.
Lab 4 — Real lib if nixpkgs available
nix repl:lf nixpkgs
lib = pkgs.lib
lib.id 42
lib.concatStringsSep "," [ "a" "b" "c" ]
lib.filterAttrs (n: v: v > 1) { a = 1; b = 2; c = 3; }
lib.recursiveUpdate { a.b = 1; } { a.c = 2; }Port Lab 1’s toUsers to use pkgs.lib instead of the stub (optional stretch).
Lab 5 — Teach-back
- Lazy value vs import of a file
- Why multi-file beats one 2k-line expression
- One
libfunction you will actually reuse
- Why
<nixpkgs>is ambient state
Common gotchas
| Symptom | Theory |
|---|---|
| Infinite recursion | Cycle in let/fixed-point |
| Import path not found | Relative to file vs cwd |
| Unexpected work during eval | Something forced a heavy binding |
cannot coerce a function |
Forgot to call imported function |
| Angle-bracket surprise versions | Unpinned NIX_PATH |
lib undefined |
Never imported pkgs/lib |
Checkpoint
- Split data + logic across ≥2 files
- Explained laziness with a trace experiment
- Imported a function file and called it
- Used map/filter or
libpipeline on users
- Documented one recursion footgun
- Notes committed
Commit
cd ~/lab/90daysofx/02-nixos/day05
git init 2>/dev/null || true
git add .
git commit -m "day05: laziness imports lib pipelines"Tomorrow
Day 6 — Derivations as idea. Evaluation produces build plans (.drv); realization fills /nix/store. You will nix build something tiny and read nix log.