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.
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; };}$ 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.
What you can do
Section titled “What you can do”| Feature | Functions and options | Purpose |
|---|---|---|
| Check a value | types.int, str, bool, listOf, attrsOf, struct, union, enum, refined, typedef, each with .verify | Test a value against a type and get a readable error string, or null when it passes |
| Merge modules | merge.evalModuleTree { specialArgs?; check?; } modules | Evaluate a module tree to config, options and provenance |
| Declare options | mkOption, merge.types.*, submodule | Typed options, nested submodules and freeform attribute sets |
| Override | mkDefault, mkForce, mkOverride, mkIf, mkMerge | Priorities and conditional definitions, with the NixOS semantics |
| Order lists | mkBefore, mkAfter, mkOrder | Place 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, lint | Definitions with no option, deprecated options, and constructs outside the subset shared with nixpkgs |
| Fold layers | algebra.record.foldLayers, foldNestedLayers | Merge ordered records field by field, with replace, append or recursive per field |
| Inject arguments | bind.wrap, bind.wrapAll, mkThunk, resolveThunks | Give module functions extra arguments such as host |
| Layered settings (experimental) | settings.mkSchema, resolveOne, resolveAll, ref, assertAcyclic | Resolve per-field values through ordered scopes, keeping each field’s chain of sources |
Details
Section titled “Details”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 # nullgen.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'.
Merging and priority
Section titled “Merging and priority”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:
| Field | Contents |
|---|---|
config | the merged values |
options | the merged option declarations, for introspection |
provenance | per option: defs, winners, priority |
undeclared | definitions that matched no option, each with its file and path |
deprecations | declared options whose type carries a deprecation message |
type | a type that nests this tree inside a parent tree as a submodule |
warmDecision, freeformConfig, freeformProv | internal 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.
Modules written for nixpkgs
Section titled “Modules written for nixpkgs”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:
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;}$ 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.
Argument injection
Section titled “Argument injection”bind.wrap gives a module function arguments the module system does not know about. The wrapped
module asks only for what is left:
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;}$ 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.
Layered settings
Section titled “Layered settings”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:
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; }$ 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).
Palettes adapted from Catppuccin (Macchiato) (MIT), Tokyo Night (Apache-2.0), gruvbox (MIT), Catppuccin (Latte) (MIT), Rosé Pine (Dawn) (MIT).