Module system in depth

Updated

July 30, 2026

Module system in depth

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 (NixOS host part) and wrote a custom one (custom modules chapter). This chapter is the types + merge pass—not another “enable nginx” tutorial.

Why this chapter 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.

Freeform modules (awareness)

Some modules accept undeclared attrs (freeform types). That is convenient for “pass settings through” and dangerous for typos. Prefer explicit options for anything Capstone treats as policy.


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.port Port range Prefer over bare int for listeners
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;
      default = 8080;
    };
  };
  default = { };
}

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

Choosing types under pressure

Situation Prefer
On/off feature mkEnableOption / types.bool
Port types.port
Hostname / domain types.strMatching or nonEmptyStr + assertion
Multi-instance services attrsOf (submodule …)
Free-form upstream JSON attrs or structured submodule if you own schema
Secret material Never as string in store; path option only

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

Debugging silent loss

When a setting “does nothing”:

  1. Search all definitions of that option path.
  2. Check for mkDefault vs plain vs mkForce.
  3. Confirm the defining module is actually imports-ed.
  4. Confirm mkIf condition is true.
  5. Confirm type merge didn’t drop invalid values (eval error should usually surface).

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).

Mechanism Fails eval? Use
Type check Yes Shape of values
assertions Yes Cross-option policy
warnings No Deprecations, soft policy
Activation scripts No (too late for CI purity) Runtime environment

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'.

# sketch
systemd.services = lib.mapAttrs' (name: inst:
  lib.nameValuePair "myapp-${name}" (lib.mkIf inst.enable {
    description = "myapp ${name}";
    # …
  })
) cfg.instances;

Watch for name collisions and firewall port unique assertions across instances.


Theory 6 — Import graphs and role modules

Pattern When
hosts/lab imports profiles/server.nix Role defaults
profiles/server imports modules/ssh.nix + modules/hardening.nix Composition
Host sets my.role = "edge" and one module switches Fewer imports; more logic

Prefer shallow import graphs you can draw. Deep diamond imports with conflicting mkForce are how priorities become superstition.


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";
      }
      {
        assertion = !(cfg.acme && cfg.listenPort == 80);
        message = "my.proxy: ACME mode expects non-80 exclusive conflict check — adjust to your design";
      }
    ];
    # wire services.nginx / caddy / traefik here with mkDefault
  };
}

Bad configs that must fail

# wrong type
my.proxy.listenPort = "443";

# failed assertion
my.proxy.enable = true;
my.proxy.listenPort = 0;

Save stderr as CI fixtures later (Capstone rebuild proof).


Lab 0 — Workspace

mkdir -p ~/lab/nixos/internals/modules
cd ~/lab/nixos/internals/modules

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.

Acceptance: 15 rows in your own language; at least 3 collection/structure types.


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>

Acceptance: Diff shows types + assertions; toplevel builds.


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.

Acceptance: Two distinct failure classes with captured stderr.


Lab 5 — Multi-instance sketch (optional)

If your Capstone has only one service instance, still sketch attrsOf submodule for a fictional second instance and document how ports would be asserted unique.


Lab 6 — Description quality audit

Pick five options you own. Ensure each has a description a stranger could use. Missing descriptions are how “options search” fails future you.

# explorative: use nixos-option on a live system or docs

Failure modes

Symptom Fix
Option used but not defined Declare option or fix path typo
Settings silently ignored Priority war; search all definitions
Infinite recursion Defaults cycle (evaluator chapter)
listOf duplicates explode unit names lib.unique or set-like design
Assertion message useless Put what and where in message

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 (evaluator)
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
Assertions only on happy path Add at least one negative fixture

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
  • Three personal gotchas

Commit

git add .
git commit -m "lab(nixos): module types catalog and redesigned module"

Write three personal gotchas before continuing.


Next

Store and daemon — multi-user trust, GC roots, sandbox, signatures.