Day 72 — Module system deep
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.
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:
- Modules ship
mkDefaultfor opinionated defaults.
- Hosts use plain assignments or selective
mkForce.
- If you need
mkForceeverywhere, 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 overrideTheory 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/day72Copy 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
- Identify untyped
attrs, raw strings for ports, or missingmkEnableOption.
- Rewrite options with proper types + descriptions.
- Move conditionals under
mkIf/mkMerge.
- Add ≥2 assertions (conflict + required field).
- 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.valueRecord 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.mdwith ≥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.