skip to content

Coming From the Module System

If you have written NixOS modules, you already know most of gen’s module system. gen-merge merges modules with the same grammar as nixpkgs’ lib.evalModules: options, imports, priorities, mkIf, submodules. It needs no nixpkgs, and it records which file set every value.

nixpkgs module systemgen
lib.evalModules { modules; specialArgs; }genMerge.evalModuleTree { specialArgs; } modules (options first, the module list last)
lib.mkOption, lib.types.*genMerge.mkOption, genMerge.types.*
mkIf, mkMerge, mkDefault, mkForce, mkOverride, mkBefore, mkAfter, mkOrderthe same names on genMerge
imports = [ … ]imports = [ … ] inside one module tree. Across classes and hosts, an aspect’s includes.
specialArgs, _module.argsspecialArgs on evalModuleTree. To bind a named argument into one module function, gen-bind’s wrap.
mkIf on a condition about the machinemkIf still works. A condition over the graph, such as “every host in prod”, is a guard on an aspect or a rule.
options.<o>.definitionsWithLocationsresult.provenance.<o>: the winning files, their priority and every definition
lib.types.submodulegenMerge.types.submodule
lib.nixosSystema terminal, called by gen-delivery once per node
a value with a type checka gen-types checker: types.int.verify 5 returns null or an error message

This tree uses options, imports, mkIf, mkDefault and a special argument:

let
m = genMerge; # inputs.gen-merge.lib, or (inputs.gen.lib.mkGenLibs { }).merge
t = m.types;
result = m.evalModuleTree { specialArgs = { site = "us-east"; }; } [ # (1)
{
options.services.ssh.enable = m.mkOption { type = t.bool; default = false; };
options.services.ssh.port = m.mkOption { type = t.int; default = 22; };
options.networking.firewall.allowedTCPPorts =
m.mkOption { type = t.listOf t.int; default = [ ]; };
options.motd = m.mkOption { type = t.str; };
}
({ config, ... }: {
networking.firewall.allowedTCPPorts =
m.mkIf config.services.ssh.enable [ config.services.ssh.port ]; # (2)
})
{ imports = [ { services.ssh.enable = true; } ]; } # (3)
{ services.ssh.port = m.mkDefault 2222; }
({ site, ... }: { motd = "welcome to ${site}"; })
];
in
result.config
  1. specialArgs reach every module as function arguments, as in nixpkgs.
  2. mkIf behaves as it does in nixpkgs.
  3. imports pulls in another module.

This evaluates to:

{
motd = "welcome to us-east";
networking.firewall.allowedTCPPorts = [ 2222 ];
services.ssh = { enable = true; port = 2222; };
}

Written with lib.evalModules and lib.mkOption, the same tree gives the same config.

Each option in the result carries its provenance:

m.evalModuleTree { } [
{ options.services.ssh.port = m.mkOption { type = t.int; default = 22; }; }
{ _file = "/etc/gen/ssh.nix"; services.ssh.port = m.mkDefault 2222; }
]
# .provenance.services.ssh.port
# ⇒ { winners = [ { file = "/etc/gen/ssh.nix"; } ]; priority = 1000; defs = [ … ]; … }

winners names the file whose definition won, and priority is its priority. mkDefault is priority 1000, the same number as in nixpkgs. See Inspection.

A definition of the wrong type fails with an error naming the option and both types:

error: gen-merge: a definition for option `port' is not of the expected type:
expected type 'int' but value "eighty" is of type 'string'
  • No lib argument. Modules receive config, options and your specialArgs. A module that reads lib fails with module argument `lib' is not defined. Pass nixpkgs’ lib in specialArgs if your modules need it.

  • A smaller native type set. int, float, str, bool, path, raw, anything, enum, nullOr, either, oneOf, listOf, attrsOf, lazyAttrsOf, submodule and deferredModule are available. port, lines, package, strMatching and functionTo are not, and there is no mkEnableOption. Write mkOption { type = types.bool; default = false; }, or bring the nixpkgs type along as shown below.

  • Argument injection by name. gen-bind’s wrap binds arguments such as host into a module function before the module system sees it:

    w = genBind.wrap { bindings = { host = { name = "web-1"; }; }; }
    ({ host, ... }: { hostName = host.name; });
    # evalModuleTree with w.module sets hostName = "web-1"

    See Automatic Argument Injection.

  • Nodes and edges. nixpkgs evaluates one machine at a time. In gen, hosts, users and features are nodes in one graph, so a module can ask which users belong to this host. From an Aspect to a NixOS System shows this.

gen-merge also accepts nixpkgs’ own lib.mkOption, lib.types and lib.mkDefault. nixpkgs option types use the same merge interface as gen-merge’s, so a module written for nixpkgs can be evaluated by gen-merge while you port it:

genMerge.evalModuleTree { specialArgs = { inherit (inputs.nixpkgs) lib; }; } [
({ lib, ... }: {
options.services.ssh.port = lib.mkOption { type = lib.types.port; default = 22; };
options.services.ssh.banner = lib.mkOption { type = lib.types.lines; default = ""; };
})
({ lib, ... }: { services.ssh.port = lib.mkDefault 2222; })
{ services.ssh.banner = "authorised use only"; }
]
# .config ⇒ { services.ssh = { banner = "authorised use only"; port = 2222; }; }

The result is identical to lib.evalModules over the same modules. gen’s benchmark corpus, run this way, produces byte-identical output. Leaf types such as port and lines add no merge work of their own, because gen-merge does the merging. A nixpkgs submodule type runs lib.evalModules for every instance, so it gives back the speed gen-merge gains on nested records. Use this mode to carry custom nixpkgs types through a port. See Benchmarks for the figures.

palette
dark
light
↑↓ select↵ apply esc close

Palettes adapted from Catppuccin (Macchiato) (MIT), Tokyo Night (Apache-2.0), gruvbox (MIT), Catppuccin (Latte) (MIT), Rosé Pine (Dawn) (MIT).