Skip to content

05 Home Manager and modules Lesson 32 20 min

Write your own module

Design an enable-gated module with typed, documented options, submodules and a modules/ directory.

Why this matters

Every programs.fish or services.openssh you have used is a file like the one you are about to write. Once your config grows past one home.nix, the way to keep it readable is the same way nixpkgs does it: small modules, each owning one options.* namespace, gated by enable. Everything here is verified with lib.evalModules on Arch.

Concept

The shape

{ lib, config, ... }:
let
  cfg = config.my.dev;
in
{
  options.my.dev = {
    enable = lib.mkEnableOption "my dev environment";
    editor = lib.mkOption {
      type = lib.types.str;
      default = "nvim";
      description = "Default editor.";
    };
  };

  config = lib.mkIf cfg.enable {
    # definitions go here
  };
}

Four conventions, each load-bearing:

  • cfg = config.my.dev reads from the fixpoint. See The module system.
  • mkEnableOption "x" is mkOption { type = bool; default = false; example = true; description = "Whether to enable x."; }. Verified: evaluating options.my.dev.enable.description gives "Whether to enable my dev environment.".
  • config = mkIf cfg.enable { ... } wraps the whole block, so a disabled module contributes nothing.
  • Own a namespace. my.* will never collide with nixpkgs.

Submodules: typed records

A submodule is a module used as a type. attrsOf (submodule ...) gives you a dictionary of typed records, like users.users or fileSystems.

{ lib, config, ... }:
let
  cfg = config.my.dev;
  projectType = lib.types.submodule {
    options = {
      path = lib.mkOption {
        type = lib.types.str;
        description = "Directory of the project.";
      };
      runtime = lib.mkOption {
        type = lib.types.enum [ "node" "python" "rust" ];
        default = "node";
        description = "Primary runtime.";
      };
      ports = lib.mkOption {
        type = lib.types.listOf lib.types.port;
        default = [ ];
        description = "Dev server ports.";
      };
    };
  };
in
{
  options.my.dev = {
    enable = lib.mkEnableOption "my dev environment";
    editor = lib.mkOption {
      type = lib.types.str;
      default = "nvim";
      description = "Default editor.";
    };
    projects = lib.mkOption {
      type = lib.types.attrsOf projectType;
      default = { };
      description = "Projects keyed by name.";
    };
  };

  options.result = lib.mkOption {
    type = lib.types.attrsOf lib.types.anything;
    default = { };
  };

  config = lib.mkIf cfg.enable {
    result.EDITOR = cfg.editor;
    result.projectDirs = lib.mapAttrsToList (_: p: p.path) cfg.projects;
    result.allPorts = lib.concatMap (p: p.ports) (lib.attrValues cfg.projects);
  };
}

result stands in for whatever a real module would set: home.packages, systemd.services, environment.variables.

A user module:

{
  my.dev.enable = true;
  my.dev.projects.lineup = {
    path = "~/dev/lineup";
    ports = [ 5173 ];
  };
  my.dev.projects.bot = {
    path = "~/dev/bot";
    runtime = "python";
  };
}
$ nix eval --impure --json --expr 'let lib = (import (builtins.getFlake "nixpkgs") {}).lib;
    in (lib.evalModules { modules = [ ./my-dev.nix ./my-dev-use.nix ]; }).config'
{"my":{"dev":{"editor":"nvim","enable":true,"projects":{"bot":{"path":"~/dev/bot","ports":[],"runtime":"python"},"lineup":{"path":"~/dev/lineup","ports":[5173],"runtime":"node"}}}},"result":{"EDITOR":"nvim","allPorts":[5173],"projectDirs":["~/dev/bot","~/dev/lineup"]}}

Defaults inside the submodule were filled in (ports: [], runtime: "node"). Without the user module, .config.result is {}: the mkIf held.

Types do the validation

runtime = "go":

error: A definition for option `my.dev.projects.x.runtime' is not of type `one of "node", "python", "rust"'. Definition values:
- In `<unknown-file>': "go"

A project with no path:

error: The option `my.dev.projects.x.path' was accessed but has no value defined. Try setting the option.

An option with no default is required. That error only fires when something reads it; laziness means an unused bad definition can hide until a later refactor touches it.

Common types and what they merge to:

Type Merge behaviour
bool, str, int, enum one definition, or conflict
listOf t concatenation, ordered by mkBefore/mkAfter
attrsOf t union, each value merged by t
lines concatenated with newlines
nullOr t null or t
submodule each field merged by its own type

Full table in the module reference.

Documenting

description is not decoration. NixOS and home-manager render it into the manual and option search. Write it as a sentence that ends with a period. Add example for anything non-obvious; mkOption { example = { g = "git"; }; } shows up next to the type. Mark internal plumbing with internal = true so it is hidden from generated docs.

Splitting into a directory

{
  imports = [
    ./modules/greeting.nix
    ./modules/pkgs.nix
  ];
  greeting = "split";
}
{"env":{"EDITOR":"nvim","PAGER":"less"},"greeting":"split","pkgs":["git","fish"],"shout":false}

One file per namespace: modules/dev.nix owns my.dev, modules/shell.nix owns my.shell. The root file only imports and sets values. Do not import a directory path; Nix resolves ./modules to ./modules/default.nix, which is fine if that file exists and itself imports the rest, confusing if it does not.

Try it

  1. Save the two modules as my-dev.nix and my-dev-use.nix and run the nix eval above. Expected output is the JSON shown.

  2. Evaluate with the module disabled:

nix eval --impure --json --expr 'let lib = (import (builtins.getFlake "nixpkgs") {}).lib; in (lib.evalModules { modules = [ ./my-dev.nix ]; }).config.result'
{}
  1. Read the generated description:
nix eval --impure --json --expr 'let lib = (import (builtins.getFlake "nixpkgs") {}).lib; in (lib.evalModules { modules = [ ./my-dev.nix ]; }).options.my.dev.enable.description'
"Whether to enable my dev environment."

Exercise

Write my.shell with enable, kind (enum of fish, zsh, default fish) and aliases (attrsOf str). Render the aliases to a lines option called rendered as fish alias name 'cmd' lines, only when enabled.

Solution
{ lib, config, ... }:
let
  cfg = config.my.shell;
in
{
  options.my.shell = {
    enable = lib.mkEnableOption "shell setup";
    kind = lib.mkOption {
      type = lib.types.enum [ "fish" "zsh" ];
      default = "fish";
      description = "Login shell.";
    };
    aliases = lib.mkOption {
      type = lib.types.attrsOf lib.types.str;
      default = { };
      example = { g = "git"; };
      description = "Shell aliases.";
    };
  };
  options.rendered = lib.mkOption {
    type = lib.types.lines;
    default = "";
  };
  config = lib.mkIf cfg.enable {
    rendered = lib.concatStringsSep "\n" (
      lib.mapAttrsToList (name: cmd: "alias ${name} '${cmd}'") cfg.aliases
    );
  };
}
$ nix eval --impure --raw --expr '... { my.shell.enable = true; my.shell.aliases = { g = "git"; ll = "ls -la"; }; } ...).config.rendered'
alias g 'git'
alias ll 'ls -la'

Setting kind = "bash" fails with not of type 'one of "fish", "zsh"'.

Trap

Writing config.result.EDITOR = lib.mkIf cfg.enable cfg.editor; per attribute instead of one mkIf around the block, then adding a new attribute and forgetting its guard. The module now leaks a definition while disabled, and the first symptom is a conflict in some unrelated host that never enabled it. One config = lib.mkIf cfg.enable { ... }; makes forgetting impossible.

Checkpoint

1/3What does `lib.mkEnableOption "foo"` produce?
2/3What is `types.attrsOf (types.submodule {...})` used for?
3/3An option has no `default`. When does the missing-value error fire?