skip to content

An Enhanced Module Type System

A server’s ssh setup is spread over three files. The ssh module declares two options, a port and a list of allowed users. A hardening module suggests a different port as a default. The server’s own file picks the port it actually uses and adds a user. gen has to decide the result and say where each value came from.

let
gen = inputs.gen.lib.mkGenLibs { };
m = gen.merge;
server = m.evalModuleTree { } [
{
_file = "ssh.nix";
options.ssh.port = m.mkOption { type = m.types.int; default = 22; };
options.ssh.allowUsers = m.mkOption { type = m.types.listOf m.types.str; default = [ ]; };
config.ssh.allowUsers = [ "deploy" ];
}
{
_file = "hardening.nix";
config.ssh.port = m.mkDefault 2200;
}
{
_file = "hosts/server.nix";
config.ssh.port = 2222;
config.ssh.allowUsers = [ "dba" ];
}
];
in
{
inherit (server) config;
why = server.provenance.ssh.port;
}
{ config.ssh = { allowUsers = [ "dba" "deploy" ]; port = 2222; };
why = {
defaulted = false;
winners = [ { file = "hosts/server.nix"; } ];
priority = 100;
defs = [ { file = "hosts/server.nix"; priority = 100; }
{ file = "hardening.nix"; priority = 1000; }
{ file = "<default>"; priority = 1500; } ];
}; }

This is the module system Nix users know from NixOS, reimplemented in gen’s merge library with no dependency on nixpkgs. Every definition has a priority, and a lower number wins. A plain definition has priority 100, mkDefault has 1000, and an option’s default has 1500. So the server’s 2222 beats the hardening module’s 2200, which beats the built-in 22. List options merge instead: allowUsers combines the users from both files. mkIf, mkForce, mkMerge and submodules work as they do in NixOS.

Compat mode. The engine also accepts nixpkgs’ own lib.mkOption, lib.types and lib.mkDefault, because nixpkgs option types use the same merge contract. An option declared with lib.types.port merges to the same result as above. This lets a module written for nixpkgs, including a custom mkOptionType, move over unchanged while a port is under way. A leaf type such as port costs nothing extra, while a nixpkgs submodule runs nixpkgs’ own evaluator for each instance and gives back the speed of gen’s engine. Benchmarks has the measurements.

Provenance. The result carries provenance next to config. For every option it records which file won, at which priority, and every definition it considered. When a value surprises you, the answer to “who set this?” is one attribute away.

Types with readable errors. Each type checks a value and returns a message when it fails. Set the server’s port to the string "2222" and evaluation stops with:

gen-merge: a definition for option `ssh.port' is not of the expected type:
expected type 'int' but value "2222" is of type 'string'

The same checks work on plain values, outside any module: gen.types.int.verify "22" returns "expected type 'int' but value \"22\" is of type 'string'", and gen.types.int.verify 22 returns null. Refined types add a condition to a type: gen.types.refined gen.types.int [ gen.types.refinements.positive ] accepts 3 and rejects 0 with the message must be positive.

A type in gen is a contract: a check attached to a value, which reports the value and the expectation it failed when the check fails. Findler and Felleisen worked out how to check contracts on higher-order values and how to report which party is to blame for a failure. gen’s refined types and kind validators are contracts in that sense, and argument injection attaches contracts to the values it passes in and names their source on failure (Automatic Argument Injection). See our reading of Findler & Felleisen (2002).

Next: Automatic Argument Injection.

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