Skip to content

Yen modules are not NixOS modules

A yen module looks like a NixOS module: a path, a function or an attrset, with imports. It is not one. yen does not use the NixOS module system and does not depend on nixpkgs: it uses Nix builtins only. A module is a function sources -> sinks, as in Cycle. This module has the three kinds of key:

variants/module-sinks.nix
# NixOS top-level `options` become module sinks. `yen.sinks` declares one: `of` checks each module's value,
# `reduce` merges the values. Any module reads the sink as an arg.
{ yen, ... }:
{
yen.sinks.admins = {
# (1)
of = yen.lib.listOf yen.lib.str; # (2)
reduce = yen.lib.reduce.flat; # (3)
};
imports = [
# (4)
{ admins = [ "ana" ]; } # (5)
{ admins = [ "bo" ]; }
(
{ yen, admins, ... }: # (6)
{
yen.aspects.wheel = {
at = yen.host.web;
nixos.users.groups.wheel.members = admins;
};
}
)
];
}
  • imports (4): more yen modules.
  • yen (1): every declaration. Here it declares a sink.
  • any other key (5): a module sink, such as admins, lib or inputs. Any module reads a sink as an arg, as at (6).

There is no options and no config. A sink that no module reads or declares is an error. So is a key that yen does not read inside yen or inside a kind. This NixOS module has both keys:

variants/nixos-style.nix
# A NixOS module, as people write them. yen reads none of it, and says so.
{
options.services.web.enable = true;
config.services.web.enable = true;
}
result
[
{
code = "unread-sink";
error = "no module reads the sink `config`";
path = [
"config"
];
}
{
code = "unread-sink";
error = "no module reads the sink `options`";
path = [
"options"
];
}
]

yen.sinks.<name> declares a module sink, as a top-level options does in NixOS. In the module above, (1) declares admins. Its keys are the keys of a kind’s sink: of (2) is the type that checks each module’s value, and reduce (3) is the merge strategy. A sink with no reduce takes one value, which an arg gets as it is. A declared sink that no module gives reduces to its empty value. The two admins lists reach the aspect wheel as one list:

result
{
groups = {
wheel = {
members = [
"ana"
"bo"
];
};
};
}

Key names load before any arg has an answer: a key name that reads an arg is an error record. imports can read a sink, such as inputs, when modules outside its own imports give it. yen loads such imports in rounds. result.modules (see Queries) shows what each module asks, gives and declares. A cycle in it is import-loop, an error record, not an infinite recursion.

What yen gets from not using nixpkgs modules

Section titled “What yen gets from not using nixpkgs modules”
  • A loop is data. In nixpkgs, a module whose imports reads config stops the evaluation with infinite recursion encountered. In yen, an imports that asks a sink that its own imports give is an import-loop error record, and the rest of the fleet still evaluates.
  • A type error is data, and it names the file. In nixpkgs, a wrong value throws, and only the first throw shows. In yen, a value that fails of is an error record with the module that gave it, and yen drops that value and continues. You see every error in one pass.
  • Validation is apart from merging. of only checks and reduce only merges. A nixpkgs type does both, and a composite type checks its items only in merge (see the caution below).
  • No priorities. There is no mkDefault or mkForce. Two different answers are an error with a fix: data never overrides data.
  • One concept for args and options. specialArgs, _module.args and options are all module sinks. A sink without a declaration gives its value as it is, such as lib or inputs.
  • The loader is data. result.modules shows what each module asks, gives and declares, and which module imports it. Yen queries and graphs read it.
  • No dependency. yen modules use Nix builtins only. They do not need nixpkgs lib.

nixpkgs modules still have more: NixOS, home-manager and nix-darwin are written for them, and yen uses those modules inside sink elements. nixpkgs also has mkIf on config, where yen has the at of an aspect.

A field type is any value with check = v: bool. yen ships the common ones, and needs no nixpkgs:

  • yen.lib.str, int, bool, port, path, attrs, any, and enum, maybe, listOf, attrsOf, functionTo. A field checks the shape of a list, an attrset or a function; result.deep checks the parts.
  • yen.lib.is f: any predicate, for example yen.lib.is builtins.isString.
  • nixpkgs types work as a predicate, but not for composites. Read the warning below.
  • nix-effects types: Int, ListOf, Record, refinements.
  • gen-types checkers, through their verify.

yen keeps validation apart from merging. A type only checks a value. How many values become one is the sink’s reduce, its merge strategy: see Sinks. A nixpkgs type does both, in check and merge. yen calls only check.

nix-effects types check deep, and the kernel can build a check for you: verified.verify type-checks an implementation against its specification and gives a plain Nix function. That function is a check:

let
inherit (fx.types) ListOf Int Record hoas verified;
H = hoas;
v = verified;
# String -> [String] -> Bool, verified by the kernel, extracted as a plain Nix function
strElem = v.verify (H.forall "t" H.string (_: H.forall "xs" (H.listOf H.string) (_: H.bool))) (
v.fn "t" H.string (t: v.fn "xs" (H.listOf H.string) (xs: v.strElem t xs))
);
in
{
yen.kinds.host.fields = {
ports = ListOf Int; # [ "a" ] fails `check`
owner = Record { name = fx.types.String; };
target = yen.lib.is (t: strElem t [ "module" "file" "package" ]); # "fleet" fails `check`
};
}

A gen-types checker has verify, which gives null or the reason a value fails. Its check follows the nixpkgs protocol and is not v: bool, so give yen a check made from verify:

yen.kinds.host.fields.ports = yen.lib.is (v: (gen.listOf gen.int).verify v == null);

The kinds hold what NixOS options hold. The instances hold what config holds:

modules/kinds.nix
# NixOS `options` become kind fields. A field type is anything with `check`: here nixpkgs types and yen.lib.is.
{ yen, ... }:
{
yen.kinds.fleet = {
spawns.host = _: yen.host;
};
yen.kinds.host = {
fields = {
addr = yen.lib.str; # (1)
firewall.enable = yen.lib.opt yen.lib.bool false; # (2) mkEnableOption: a nested field with a default
firewall.ports = yen.lib.opt (yen.lib.listOf yen.lib.port) [ ]; # (3) checks every item
};
spawns.user = yen.lib.pointing "on" yen.user; # (4)
sinks.nixos.reduce = yen.lib.reduce.merge; # (5)
};
# attrsOf (submodule { … }) in NixOS: here a kind, whose instances have names, refs and their own errors
yen.kinds.user.fields = {
on = yen.kinds.host; # (6)
shell = yen.lib.opt yen.lib.str "bash";
};
}

At (1), addr is a field, as an option has a type. At (2), a nested field with a default is the answer to mkEnableOption. At (3), listOf checks every item. At (4), spawns.user makes each user a scope under its host. At (5), the sink nixos collects the NixOS modules. At (6), on is a ref from a user to its host. The instances hold the config:

modules/fleet.nix
# NixOS `config` becomes instance data, and `mkIf` becomes `at`.
{ yen, ... }:
{
yen.fleet.main = { };
yen.flake = { }; # (1) no flake outputs: this page reads the eval result
yen.host = {
web = {
addr = "10.0.0.1";
firewall.enable = true; # (2)
firewall.ports = [ 443 ];
};
db.addr = "10.0.0.2";
};
yen.user.ana.on = yen.host.web; # (3)
yen.aspects.firewall = {
# (4) mkIf config.firewall.enable. `at` runs at every scope: guard with the kind, then read the field
at = yen.lib.all [
yen.kinds.host
(s: s.firewall.enable)
];
nixos =
{ host, ... }:
{
networking.firewall.allowedTCPPorts = host.firewall.ports;
};
};
}

At (1), the flake has no outputs, because this page reads the eval result. At (2) and (3), instance data takes the place of config. At (4), at takes the place of mkIf. This table lists each NixOS pattern:

NixOS yen
options.x = mkOption { type; default; } yen.kinds.K.fields.x = yen.lib.opt type default
a top-level option that many modules set, such as flake.lib a module sink: yen.sinks.x = { of = type; reduce = …; }, then x = v; in each module
mkEnableOption a nested field: fields.firewall.enable = yen.lib.opt yen.lib.bool false
default = f config yen.lib.derive type f: see Types
config.x = v instance data: yen.host.web.x = v
mkIf config.firewall.enable at = yen.lib.all [ yen.kinds.host (s: s.firewall.enable) ]
list and attrset options from many modules a sink with a reduce: see Sinks
mkDefault, mkForce a type default, then the instance value. Two values for one leaf are two-answers
assertions yen.invariants, or an aspect’s errors key
imports, specialArgs imports, and the sinks of the root module: inherit inputs lib;
attrsOf (submodule { … }) a kind with instances, below
result
{
"fleet:main/host:web" = {
networking = {
firewall = {
allowedTCPPorts = [
443
];
};
};
};
}

In NixOS, users.users.<name> and services.nginx.virtualHosts.<name> are attrsOf (submodule { … }): a module inside each value, with its own options. submoduleWith adds args and more modules to each one. Each value is merged into one attrset, and it has no identity outside its parent.

In yen, a named submodule is a kind. user above is one: each user is an instance with a name, a ref to its host (on = yen.kinds.host), defaults (shell), and its own errors with a path. spawns makes each user a scope under its host, so aspects can be active on users, and queries can find them:

result
[
"fleet:main"
"fleet:main/host:db"
"fleet:main/host:web"
"fleet:main/host:web/user:ana"
]

A submodule with no names, like services.nginx, is a nested field. firewall.enable and firewall.ports above are one. For how config as a fixpoint compares, see Fixpoints and dependencies.

ContributeCommunitySponsor