Day 72 — Module system deep

Updated

July 30, 2026

Day 72 — Module system deep

Stage VII · ~4h (theory-heavy)
Goal: Use a real type catalog, compose with mkIf / mkMerge / priorities correctly, add assertions, and redesign one production-shaped module so bad configs fail at eval time.

Note

You already consumed modules (Stage II) and wrote a custom one (Day 19). Today is the Zai-style types + merge pass—not another “enable nginx” tutorial.

Why this day exists

Untyped attrs options accept anything until activation or first request fails. Priority mistakes silently lose settings (mkDefault overridden by accident, or mkForce used as a hammer). Capstone hosts need modules that reject nonsense early and merge predictably across roles.


Theory 1 — Anatomy of a module (recall with precision)

A module is a function (or attrset) producing some of:

{ config, lib, pkgs, ... }:
{
  imports = [ ./peer.nix ];
  options = { /* option declarations */ };
  config = { /* option definitions */ };
}
Field Role
imports DAG of other modules
options Schema: types, defaults, descriptions
config Values / definitions (often under mkIf)
freeform / _module Advanced; respect unless you know why

Evaluation merges all definitions according to type + priority, then checks types and assertions.


Theory 2 — Type catalog (practical reference)

Import lib.types as types in real modules. Below is the catalog you should be able to choose from, not memorize constructor source.

Scalars & paths

Type Accepts Notes
types.bool true/false Prefer over “stringly” enables
types.int / types.ints.positive / types.ints.u16 Integers with bounds Ports, counts
types.float Floats Rare in OS config
types.str String Most text
types.nonEmptyStr Non-empty string Hostnames, user names
types.singleLineStr No newlines Avoid injection in unit files
types.lines / types.commas Multi-line / comma merge Distinct merge semantics
types.path Path-like Coerces some strings; store paths common
types.package Derivation/package For environment.systemPackages style
types.shellPackage Package with shell Specialized
types.passwdEntry types.str Shadow-safe strings Passwords (still don’t put secrets in store)
types.strMatching "regex" Pattern Domains, tokens shape
types.enum [ "a" "b" ] Closed set Modes
types.either t1 t2 Union e.g. bool or str
types.oneOf [ t1 t2 … ] Multi union Prefer small sets
types.nullOr t null or t Optional values
types.uniq t Unique merge Only one definition wins (strict)
types.anything Escape hatch Avoid in public modules
types.raw Untyped raw Advanced / internal

Collections

Type Merge behavior (intuition)
types.listOf t Concatenate definitions
types.attrsOf t Deep-merge by attr name
types.lazyAttrsOf t Lazier attrs (perf / recursion)
types.attrs Freeform attrs (weak)
types.attrsWith { … } Configurable attrs (modern)
types.loaOf t Deprecated list-or-attrs legacy

Structure

Type Use
types.submodule { options = …; } Nested option trees
types.submoduleWith { modules = …; } Modular submodules
types.deferredModule Module value delayed
types.optionType Type of types (rare)

Function-ish / paths to callables

Type Use
types.functionTo t Options that are functions
types.raw + convention When types can’t express it

Useful helpers

types.addCheck t predicate
types.coercedTo from coerceFn to
lib.mkOption {
  type = types.submodule {
    options.enable = lib.mkEnableOption "this feature";
    options.port = lib.mkOption {
      type = types.port;  # alias around ints
      default = 8080;
    };
  };
  default = { };
}

lib.mkEnableOption "desc" → bool option, default false, standard description.


Theory 3 — Definitions: mkIf, mkMerge, priorities

mkIf

config = lib.mkIf config.services.myapp.enable {
  users.users.myapp = { isSystemUser = true; group = "myapp"; };
  # ...
};

False conditions drop definitions (they do not define null everywhere).

mkMerge

config = lib.mkMerge [
  (lib.mkIf cfg.enable {})
  (lib.mkIf cfg.tls {})
  { assertions = []; }
];

Prefer mkMerge over huge nested // trees when multiple independent conditionals apply.

Priority ladder (high level)

Helper Intent
mkOptionDefault Lowest-ish option default layer
mkDefault Soft default; user config should win
mkOverride n Numeric priority (mkDefault ≈ 1000, normal 100, …)
mkForce High priority “I really mean this”
mkFixStrictly / order-related Advanced ordering

Discipline:

  1. Modules ship mkDefault for opinionated defaults.
  2. Hosts use plain assignments or selective mkForce.
  3. If you need mkForce everywhere, your module boundaries are wrong.

Example conflict

# module
services.openssh.enable = lib.mkDefault true;

# host
services.openssh.enable = false;  # wins over mkDefault
# role module
networking.firewall.enable = lib.mkForce true;

# host tries
networking.firewall.enable = false;  # loses unless higher override

Theory 4 — Assertions & warnings

config = {
  assertions = [
    {
      assertion = !(cfg.enable && cfg.package == null);
      message = "myapp: enable requires package";
    }
    {
      assertion = cfg.port != 22 || !config.services.openssh.enable;
      message = "myapp: port 22 conflicts with OpenSSH on this host pattern";
    }
  ];
  warnings = lib.optional cfg.legacyMode ''
    myapp.legacyMode is deprecated; migrate to settings.v2
  '';
};

Assertions fail evaluation—perfect for capstone CI (nix flake check).


Theory 5 — Submodules & attrsOf services

Pattern for multi-instance:

options.services.myapp.instances = lib.mkOption {
  type = types.attrsOf (types.submodule ({ name, ... }: {
    options = {
      enable = lib.mkEnableOption "instance ${name}";
      port = lib.mkOption { type = types.port; };
    };
  }));
  default = { };
};

Then map instances to systemd units with lib.mapAttrs'.


Worked example — Redesign sketch

Before (weak)

{ config, lib, ... }:
{
  options.my.proxy = lib.mkOption {
    type = lib.types.attrs;
    default = { };
  };
  config = {
    # hope the attrs are right...
  };
}

After (typed)

{ config, lib, pkgs, ... }:
let
  inherit (lib) types mkIf mkOption mkEnableOption;
  cfg = config.my.proxy;
in {
  options.my.proxy = {
    enable = mkEnableOption "edge reverse proxy front door";
    domain = mkOption {
      type = types.strMatching "[a-z0-9.-]+";
      example = "lab.example.com";
      description = "Primary public hostname";
    };
    upstream = mkOption {
      type = types.str;
      example = "http://127.0.0.1:8080";
    };
    listenPort = mkOption {
      type = types.port;
      default = 443;
    };
    acme = mkOption {
      type = types.bool;
      default = true;
    };
  };

  config = mkIf cfg.enable {
    assertions = [
      {
        assertion = cfg.listenPort != 0;
        message = "my.proxy.listenPort must be non-zero";
      }
    ];
    # wire services.nginx / caddy / traefik here with mkDefault
  };
}

Lab 0 — Workspace

mkdir -p ~/lab/90daysofx/02-nixos/day72
cd ~/lab/90daysofx/02-nixos/day72

Copy or reference one real module from your lab flake (proxy, app, or secrets-adjacent).


Lab 1 — Type catalog cheat sheet (you write it)

In TYPES.md, list at least 15 types from the catalog with:

  • one-line purpose
  • one good example value
  • one bad value that should fail

Do not paste the table from this chapter blindly—rewrite in your words.


Lab 2 — Redesign one module

  1. Identify untyped attrs, raw strings for ports, or missing mkEnableOption.
  2. Rewrite options with proper types + descriptions.
  3. Move conditionals under mkIf / mkMerge.
  4. Add ≥2 assertions (conflict + required field).
  5. Ensure host still evaluates:
nix eval .#nixosConfigurations.<host>.config.system.build.toplevel --raw >/dev/null
# or
nixos-rebuild build --flake .#<host>

Lab 3 — Priority drill

Create a tiny three-file demo (can be pure lib.evalModules):

# eval-demo.nix — sketch
{ lib ? (import <nixpkgs> { }).lib }:
lib.evalModules {
  modules = [
    ({ lib, ... }: {
      options.value = lib.mkOption { type = lib.types.int; };
      config.value = lib.mkDefault 1;
    })
    ({ lib, ... }: {
      config.value = 2; # plain
    })
    ({ lib, ... }: {
      # toggle these in experiments:
      # config.value = lib.mkForce 3;
    })
  ];
}
nix eval -f eval-demo.nix config.value

Record the winner for: default vs plain vs force. Paste results into PRIORITIES.md.


Lab 4 — Intentional bad config

Against your redesigned module, write bad-host.nix snippets that must fail eval (wrong type, failed assertion). Run them and save stderr excerpts—these become CI fixtures later (Day 86).


Common gotchas

Symptom What to do
The option … is used but not defined Missing option decl or wrong path
Infinite recursion in modules Option default depends on own config cycle (Day 71)
Settings silently ignored Lost priority war; check mkDefault/mkForce
listOf duplicates explode Dedup with lib.unique or design set-like options
Submodule names vs name arg Use { name, ... }: pattern carefully
Secrets in typed options Types don’t encrypt; still use sops/agenix
Over-using types.attrs Prefer attrsOf submodule or explicit options

Checkpoint

  • TYPES.md with ≥15 types in your words
  • One production module redesigned with real types
  • ≥2 assertions
  • Priority demo understood (PRIORITIES.md)
  • Bad configs fail at eval with clear messages
  • Host/toplevel still builds

Commit

git add .
git commit -m "day72: module types catalog and redesigned module"

Write three personal gotchas before continuing.

Tomorrow

Day 73 — Store & daemon: multi-user trust, sandbox namespaces, substituter signatures, and reading a sandbox build log.