skip to content

Automatic Argument Injection

A server’s monitoring feature needs to know which host it runs on, so that it can publish a scrape target such as web-1:9100. The feature is written once and used on many hosts. The module asks for host in its argument list, and gen supplies the right host for each use.

let
gen = inputs.gen.lib.mkGenLibs { };
m = gen.merge;
# A feature module that asks for the host it configures.
monitoring = { host, ... }: {
monitoring.target = "${host.name}:9100";
};
forWeb1 = gen.bind.wrap {
bindings.host = { name = "web-1"; };
} monitoring;
web1 = m.evalModuleTree { } [
{ options.monitoring.target = m.mkOption { type = m.types.str; }; }
forWeb1.module
];
in
web1.config.monitoring.target
"web-1:9100"

wrap reads the argument names the module declares. For each name that has a binding, it passes the bound value in. Arguments without a binding, such as config or lib, are left for the module system to supply as usual. Bindings the module does not ask for are ignored, so one set of bindings can serve every feature a host uses.

The wrapped result also describes the module’s interface. forWeb1.signature lists the arguments that were bound and the ones still required, so a framework can check a module’s needs before it evaluates anything. If a module asks for host and nothing binds it, evaluation fails with:

gen-merge: module argument `host' is not defined

A binding can carry a contract and a note of where its value came from:

gen.bind.wrap {
bindings.host = { name = "web-1"; };
contracts.host = gen.bind.contract.hasFields [ "name" "system" ];
provenance.host = { source = "fleet"; scope = "host=web-1"; };
} monitoring

The host value above has no system field, so reading it fails with a message that names the missing field and the source:

gen-bind: contract violation: value must have fields: name, system
(provided by 'fleet' at scope 'host=web-1')

The contract runs when the module reads host. A module that never reads a binding never pays for its check.

When a binding has the same name as an argument the module system already provides, such as lib, the binding wins by default. You can choose per name to let the module system win or to make the collision an error.

A service api listens on port 8080. Its NixOS host, its cloud firewall rule and its Kubernetes Service all need that port. Each target has its own module format, and the same binding reaches all three.

let
gen = inputs.gen.lib.mkGenLibs { };
m = gen.merge;
service = { name = "api"; port = 8080; };
# Three modules, each shaped for a different tool, all asking for `service`.
forNixos = { service, ... }: {
services.nginx.virtualHosts.${service.name}.listen = [ { port = service.port; } ];
};
forTerranix = { service, ... }: {
resource.aws_security_group_rule.${service.name} = {
from_port = service.port;
to_port = service.port;
};
};
forKubernetes = { service, ... }: {
services.${service.name}.spec.ports = [ { port = service.port; } ];
};
# Evaluate one module with the value bound. The freeform type accepts any shape.
render = module:
(m.evalModuleTree { } [
{ freeformType = m.types.lazyAttrsOf m.types.anything; }
(gen.bind.wrap { bindings = { inherit service; }; } module).module
]).config;
in
{
nixos = render forNixos;
terranix = render forTerranix;
kubernetes = render forKubernetes;
}
{ kubernetes = { services.api.spec.ports = [ { port = 8080; } ]; };
nixos = { services.nginx.virtualHosts.api.listen = [ { port = 8080; } ]; };
terranix = { resource.aws_security_group_rule.api = { from_port = 8080; to_port = 8080; }; }; }

gen composes a configuration in its own evaluation, without nixpkgs, and passes the target tool plain values such as service. gen’s own types stay on gen’s side, so the target never has to understand them. This boundary is a declared interim: the target’s own checks on these values are off by default, and each place a value is handed over without that check is recorded as a declared opt-out. Full nixpkgs is loaded only where a target needs it, for example when building a NixOS system.

The example above evaluates each module with gen’s engine to show the values. In a framework, a terminal hands the same wrapped modules to the target’s own evaluator (Delivery). The adios module system uses the same value-passing boundary.

An implicit parameter is a value a function uses without every caller passing it by hand. The nearest enclosing binding supplies it. Lewis et al. showed how to give such parameters static types, so a function’s type records which implicit values it needs. A gen module’s argument list plays the same role: it declares what the module needs, and the framework binds it where the module is used. See our reading of Lewis et al. (2000).

Next: Rules That Add Relationships.

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