Day 2 — Functions, Laziness & nixpkgs lib
Day 2 — Functions, Laziness & nixpkgs lib
Day 4 — Language: functions
Stage I · ~4h (theory-heavy)
Goal: Write and call Nix functions fluently—especially attribute-set pattern arguments, defaults, @-binding, and ellipsis—so flake and module snippets stop looking like magic.
Why this day exists
In Nix, packages, shells, overlays, and modules are functions returning values. If you only know x: x + 1, you cannot read nixpkgs. Today is the real functional core.
Theory 1 — Everything callable is a lambda
let
inc = x: x + 1;
add = x: y: x + y; # curried
in
add 2 3 # 5Application is juxtaposition
f x # call f with x
f x y # (f x) y if curried
f { a = 1; } # call with one attrset argParenthesize when needed: (f x) + 1, map (x: x + 1) list.
Functions are values
let
ops = {
inc = x: x + 1;
double = x: x * 2;
};
in
ops.double (ops.inc 3) # 8Theory 2 — Attrset pattern arguments (the Nix idiom)
Almost every public Nix API looks like:
{ a, b }: a + bCall site:
({ a, b }: a + b) { a = 1; b = 2; }Destructuring is the point
{ hostName, user }: {
networking.hostName = hostName;
users.users.${user}.isNormalUser = true;
}You name the fields you need. Extra fields are an error unless you allow them (ellipsis—below).
Theory 3 — Default arguments with ?
{ force ? false, retries ? 3 }: {
inherit force retries;
}f { } # force=false, retries=3
f { force = true; } # force=true, retries=3
f { retries = 10; } # force=false, retries=10Order of attributes in the pattern does not matter at the call site; names bind by key.
Defaults can be expressions
{ pkgs, system ? builtins.currentSystem }:
# system falls back if omitted
{ inherit pkgs system; }Be careful: defaults that force heavy evaluation run when the binding is used (laziness—Day 5), but bad defaults can still surprise you.
Theory 4 — Ellipsis ... (allow unknown attributes)
{ a, b, ... }: a + bCall with extras:
({ a, b, ... }: a + b) { a = 1; b = 2; c = 99; } # 3 — c ignoredWithout ...:
({ a, b }: a + b) { a = 1; b = 2; c = 99; }
# error: unsupported argument 'c'When to use ellipsis
| Situation | Ellipsis? |
|---|---|
| Strict public API | Often no—catch typos |
| Pass-through / forward kwargs | Yes |
| Module-style “rest of config” | Sometimes |
House rule: use ... when forwarding; avoid it when you want misspelled option names to fail loudly.
Theory 5 — @ binding (name the whole set)
args@{ a, b, ... }: {
inherit a b;
allKeys = builtins.attrNames args;
}Or:
{ a, b, ... }@args: … # same idea, different sugar positionWhy @ exists
- Forward the full attrset to another function
- Inspect extras
- Merge overrides
let
f = { a, b ? 0, ... }@args:
g (args // { b = b + 1; });
in
fClassic flake-shaped pattern:
outputs = inputs@{ self, nixpkgs, ... }:
let
system = "x86_64-linux";
pkgs = import nixpkgs { inherit system; };
in {
packages.${system}.default = pkgs.hello;
};You will write this shape on Day 8; understand the inputs@{ … } part now.
Theory 6 — Composition and higher-order helpers
map / filter (builtins)
map (x: x * 2) [ 1 2 3 ]
# [ 2 4 6 ]
builtins.filter (x: x > 1) [ 0 1 2 3 ]
# [ 2 3 ]Function returning function
let
multiply = n: x: n * x;
triple = multiply 3;
in
triple 4 # 12enableIf-style helper (preview of module style)
let
enableIf = cond: value: if cond then value else { };
in
enableIf true { services.foo.enable = true; }Real modules use lib.mkIf—same idea, better merge semantics.
Theory 7 — Fixed-point awareness (not mastery)
You will see:
lib.fixpoint (self: {
a = 1;
b = self.a + 1;
})Idea: define an attrset in terms of its final form. Overlays and package sets use this heavily. Today: recognize it; do not invent fixed points for homework.
Informal:
self → { a = 1; b = self.a + 1; } → { a = 1; b = 2; }
Theory 8 — Call patterns you will see in the wild
1. Package “callPackage” shape
{ lib, stdenv, fetchurl }:
stdenv.mkDerivation { … }Someone injects dependencies by calling the function with an attrset of packages.
2. Overlay shape
final: prev: {
myPkg = prev.hello.overrideAttrs (old: { … });
}Two arguments, both attrsets of packages (final = after overlays, prev = before this overlay).
3. Module shape (preview)
{ config, lib, pkgs, ... }: {
options = { … };
config = { … };
}Ellipsis is standard—modules receive many arguments.
Worked examples bank
Example A — Defaults + ellipsis
let
mkService = { name, port ? 8080, enable ? true, ... }: {
inherit name port enable;
};
in
mkService { name = "api"; host = "ignored-extra"; }Example B — @ forward
let
core = { a, b }: a + b;
wrap = args@{ a, b ? 0, ... }:
core { inherit a; b = b + 1; };
in
wrap { a = 10; b = 1; extra = true; }
# → 12Example C — User skeleton helper
let
mkUser = {
name,
groups ? [ "wheel" ],
keys ? [ ],
}: {
users.users.${name} = {
isNormalUser = true;
extraGroups = groups;
openssh.authorizedKeys.keys = keys;
};
};
in
mkUser {
name = "alice";
keys = [ "ssh-ed25519 AAAA… alice@lab" ];
}Example D — Package list merge helper
let
mergePkgs = lists: builtins.concatLists lists;
in
mergePkgs [
[ "git" "vim" ]
[ "curl" ]
[ "htop" ]
]Lab 1 — Workspace
mkdir -p ~/lab/90daysofx/02-nixos/day04
cd ~/lab/90daysofx/02-nixos/day04Create helpers.nix:
rec {
inc = x: x + 1;
add = { a, b ? 0 }: a + b;
mkHost = { hostName, user, sshPort ? 22, ... }: {
networking = {
inherit hostName;
firewall.allowedTCPPorts = [ sshPort ];
};
users.users.${user}.isNormalUser = true;
};
enableIf = cond: attrs: if cond then attrs else { };
mergeLists = builtins.concatLists;
}Evaluate pieces:
nix eval --file ./helpers.nix --apply 'h: h.add { a = 2; b = 3; }'
nix eval --file ./helpers.nix --apply 'h: h.mkHost { hostName = "lab"; user = "alice"; }'Lab 2 — Strict vs ellipsis
Create strict.nix and loose.nix:
# strict.nix
{ a, b }: a + b# loose.nix
{ a, b, ... }: a + bnix eval --file ./strict.nix --apply 'f: f { a = 1; b = 2; c = 3; }' # should fail
nix eval --file ./loose.nix --apply 'f: f { a = 1; b = 2; c = 3; }' # 3Journal: when would failure be better?
Lab 3 — @ args inventory
# inspect.nix
args@{ a, b ? 1, ... }: {
sum = a + b;
keys = builtins.attrNames args;
raw = args;
}nix eval --file ./inspect.nix --apply 'f: f { a = 5; c = "extra"; }'Confirm keys includes c and default b behavior matches your mental model.
Lab 4 — Compose three helpers into a “mini host”
In mini-host.nix, import/use helpers to produce one attrset that includes:
- Hostname + admin user (via
mkHost)
- Conditionally enabled
services.demowithenableIf
- Merged package string lists
nix eval --file ./mini-host.nixLab 5 — Read real shapes (optional but high value)
If network/cache available:
nix repl:lf nixpkgs
# skim types only — do not deep dive packaging yet
builtins.typeOf pkgs.helloOpen any small module online or local nixpkgs checkout later—note the header:
{ config, lib, pkgs, ... }:Map each binder to today’s theory.
Common gotchas
| Symptom | Theory |
|---|---|
called without required argument |
Missing pattern field |
unsupported argument '…' |
Need ... or remove extra |
| Defaults not applied | You passed null explicitly—defaults only for missing keys |
f x y vs f {…} confusion |
Know arity/shape of f |
| Forgetting parentheses around lambdas in lists | [ (x: x) ] not [ x: x ] |
| Trying defaults with positional args | Defaults are for attr patterns |
| Infinite recursion | Self-call without base case |
Checkpoint
- Write a curried and an attr-pattern function
- Use
?defaults correctly
- Explain ellipsis trade-off (strict vs loose)
- Use
@to inspect or forward args
- Build
mkHost/enableIfstyle helpers
- Recognize module/overlay call shapes
Commit
cd ~/lab/90daysofx/02-nixos/day04
git init 2>/dev/null || true
git add .
git commit -m "day04: language functions patterns defaults ellipsis"Tomorrow
Day 5 — Laziness, imports & lib. Multi-file expressions, when work actually runs, and a first taste of lib pipelines.
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.