Day 3 — Language: values & attrsets

Updated

July 30, 2026

Day 3 — Language: values & attrsets

Stage I · ~4h (theory-heavy)
Goal: Read and write Nix values, attrsets, letin, and inherit well enough to model config-shaped data and follow nixpkgs snippets.

Note

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 characters

Import 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 — letin (local bindings)

let
  a = 1;
  b = a + 2;
in
  a + b
# → 4

Rules that matter

  1. Bindings in one let can refer to each other (order-independent among pure values).
  2. Scope ends at the in expression.
  3. Prefer let over repeating huge expressions.
  4. Infinite recursion is possible if a binding depends on itself without a fixed point.
let x = x + 1; in x   # boom: infinite recursion

Nested 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-cleverness

assert cond; value fails evaluation if cond is false—useful later for module assertions; try once today:

let x = 2; in assert x > 0; x

Theory 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 hostName avoids repetition
  • Lists for multi-valued options

Worked examples bank

Example A — REPL tour

nix repl
1 + 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.enable

Missing 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/day03

Create 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
  deep

Confirm 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:

  1. Hostname + domain
  2. Two users (one admin)
  3. Three “packages” as strings
  4. Firewall ports for SSH + one app
  5. 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:

  1. Difference between path and string
  2. Why lists use spaces not commas
  3. What // does to nested sets
  4. When you would use inherit
  5. Why broad with is 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 letin and inherit deliberately
  • Demonstrated shallow // pitfall
  • Evaluated a .nix file with nix 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.