skip to content

Typed Configuration

Three modules set a host’s SSH port. gen merges them and records which file set the value, at which priority, and which definitions lost.

ssh-port.nix
let
gen = (builtins.getFlake "github:sini/gen").lib.mkGenLibs { };
inherit (gen.merge) evalModuleTree mkOption mkDefault types;
result = evalModuleTree { } [
{
_file = "/modules/ssh.nix";
options.ssh.port = mkOption { type = types.int; default = 22; };
options.ssh.allowUsers = mkOption { type = types.listOf types.str; default = [ ]; };
}
{ _file = "/modules/hardening.nix"; ssh.port = mkDefault 2222; ssh.allowUsers = [ "deploy" ]; }
{ _file = "/modules/web-1.nix"; ssh.port = 22022; ssh.allowUsers = [ "dba" ]; }
];
in
{
config = result.config;
port = { inherit (result.provenance.ssh.port) winners priority defs; };
}
Terminal window
$ nix eval --impure --json --file ssh-port.nix
{
"config": { "ssh": { "allowUsers": ["dba", "deploy"], "port": 22022 } },
"port": {
"defs": [
{ "file": "/modules/web-1.nix", "priority": 100 },
{ "file": "/modules/hardening.nix", "priority": 1000 },
{ "file": "<default>", "priority": 1500 }
],
"priority": 100,
"winners": [ { "file": "/modules/web-1.nix" } ]
}
}

The module language is the one NixOS uses: options, config, mkDefault, mkIf, mkMerge, types.*. The engine needs no nixpkgs. Every result carries provenance, one record per option.

FeatureFunctions and optionsPurpose
Check a valuetypes.int, str, bool, listOf, attrsOf, struct, union, enum, refined, typedef, each with .verifyTest a value against a type and get a readable error string, or null when it passes
Merge modulesmerge.evalModuleTree { specialArgs?; check?; } modulesEvaluate a module tree to config, options and provenance
Declare optionsmkOption, merge.types.*, submoduleTyped options, nested submodules and freeform attribute sets
OverridemkDefault, mkForce, mkOverride, mkIf, mkMergePriorities and conditional definitions, with the NixOS semantics
Order listsmkBefore, mkAfter, mkOrderPlace a definition’s elements before or after the others
Trace a value.provenance.<option>The winning file, its priority and every definition considered
Find mistakes.undeclared, .deprecations, lintDefinitions with no option, deprecated options, and constructs outside the subset shared with nixpkgs
Fold layersalgebra.record.foldLayers, foldNestedLayersMerge ordered records field by field, with replace, append or recursive per field
Inject argumentsbind.wrap, bind.wrapAll, mkThunk, resolveThunksGive module functions extra arguments such as host
Layered settings (experimental)settings.mkSchema, resolveOne, resolveAll, ref, assertAcyclicResolve per-field values through ordered scopes, keeping each field’s chain of sources

Every gen type is a record with a name, a check predicate and a verify function. verify returns null when the value fits and a message when it does not. Nested types name the position that failed:

gen.types.int.verify 22 # null
gen.types.int.verify "22" # "expected type 'int' but value \"22\" is of type 'string'"
(gen.types.checkedListOf gen.types.str).verify [ "deploy" 7 ]
# "in listOf<string> element: expected type 'string' but value 7 is of type 'int'"

The module engine uses the same checks. A definition of the wrong type fails with gen-merge: a definition for option `ssh.port' is not of the expected type: expected type 'int' but value "22" is of type 'string'.

The lowest priority number wins, and definitions at the same priority merge. A plain definition is priority 100, mkDefault 1000, an option’s default 1500, and mkForce 50. Definitions at equal priority merge in reverse module order, as in nixpkgs: that is why allowUsers above is [ "dba" "deploy" ]. mkBefore and mkAfter reorder list elements after priorities are resolved.

evalModuleTree returns these fields:

FieldContents
configthe merged values
optionsthe merged option declarations, for introspection
provenanceper option: defs, winners, priority
undeclareddefinitions that matched no option, each with its file and path
deprecationsdeclared options whose type carries a deprecation message
typea type that nests this tree inside a parent tree as a submodule
warmDecision, freeformConfig, freeformProvinternal records an incremental re-run reads (see Incremental Rebuild)

A definition for an option that does not exist fails with The option `ssh.prot' does not exist. Definition values: and one - In `/modules/web-1.nix': 1 line naming the defining file, then nixpkgs’ suggestion over the sibling option names, Did you mean `ssh.port'?, as nixpkgs does. With check = false the evaluation continues, and undeclared lists { file = "/modules/web-1.nix"; path = [ "ssh" "prot" ]; }.

gen’s merge engine is tested against nixpkgs’ lib.evalModules for the module grammar the two share. lint flags a module that uses a construct outside that shared subset.

evalModuleTree also accepts options declared with nixpkgs’ lib.mkOption and lib.types, with no adapter. The same modules give the same config under both engines:

nixpkgs-types.nix
let
gen = (builtins.getFlake "github:sini/gen").lib.mkGenLibs { };
lib = (builtins.getFlake "github:NixOS/nixpkgs/nixos-unstable").lib;
modules = [
{ options.ssh.port = lib.mkOption { type = lib.types.port; default = 22; };
options.ssh.allowUsers = lib.mkOption { type = lib.types.listOf lib.types.str; default = [ ]; }; }
{ ssh.port = lib.mkDefault 2222; ssh.allowUsers = [ "deploy" ]; }
{ ssh.port = 22022; ssh.allowUsers = [ "dba" ]; }
];
in
{
gen = (gen.merge.evalModuleTree { } modules).config;
nixpkgs = (lib.evalModules { inherit modules; }).config;
}
Terminal window
$ nix eval --impure --json --file nixpkgs-types.nix
{
"gen": { "ssh": { "allowUsers": ["dba", "deploy"], "port": 22022 } },
"nixpkgs": { "ssh": { "allowUsers": ["dba", "deploy"], "port": 22022 } }
}

This lets you port a module tree one option at a time. Leaf types such as port and str add no cost. A nixpkgs submodule hands its merge back to lib.evalModules for every instance, so it loses gen’s speed advantage for that subtree. Replace those with merge.types.submodule when speed matters.

bind.wrap gives a module function arguments the module system does not know about. The wrapped module asks only for what is left:

hostname.nix
let
gen = (builtins.getFlake "github:sini/gen").lib.mkGenLibs { };
inherit (gen) merge bind;
wrapped = bind.wrap { bindings = { host = { name = "web-1"; }; }; }
({ host, config, ... }: { networking.hostName = host.name; });
result = merge.evalModuleTree { } [
{ options.networking.hostName = merge.mkOption { type = merge.types.str; }; }
wrapped.module
];
in
{
hostName = result.config.networking.hostName;
stillAsks = wrapped.advertisedArgs;
}
Terminal window
$ nix eval --impure --json --file hostname.nix
{ "hostName": "web-1", "stillAsks": { "config": false } }

host came from the binding. config is still requested from the module system, and false means it is a required argument. See Automatic Argument Injection.

This feature is experimental. A settings schema names its fields and how each merges. resolveOne folds ordered layers, least specific first, and keeps where each field’s value came from:

firewall-settings.nix
let
gen = (builtins.getFlake "github:sini/gen").lib.mkGenLibs { };
firewall = {
name = "firewall";
id_hash = gen.identity.hashIdentity "aspect" [ "name" ] (label: { name = "firewall"; }.${label});
};
schema = gen.settings.mkSchema firewall {
allowedTCP = { default = [ 22 ]; merge = "append"; };
logLevel = { default = "warn"; };
};
# options first (`{ resolveRef; strict; }`, none here), then the schema, then the layers
result = gen.settings.resolveOne { } schema [
{ scope = { env = "prod"; }; rendered = "prod"; via = null; value = { allowedTCP = [ 443 ]; }; }
{ scope = { host = "web-1"; }; rendered = "web-1"; via = null;
value = { allowedTCP = [ 80 ]; logLevel = "info"; }; }
];
in
{ inherit (result) value; logLevelFrom = map (p: p.rendered) result.provenance.logLevel; }
Terminal window
$ nix eval --impure --json --file firewall-settings.nix
{ "logLevelFrom": ["default", "web-1"], "value": { "allowedTCP": [22, 443, 80], "logLevel": "info" } }

For how gen’s types relate to the NixOS module system, see An Enhanced Module Type System.


Provided by gen-types (types), gen-merge (module merging and provenance), gen-algebra (layered records), gen-bind (argument injection) and gen-settings (layered settings).

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