Reference
Library
collective-lib is the functional library underneath Collective: typed enums with exhaustive dispatch, polymorphic dispatch on values, variadic functions, ordered collections, readable errors, inline tests, and the typed data that describes machines and desktops. It is public, in collective-public; a small private layer in the root flake extends it with display and input libraries, secrets and machine keys.
Typed and untyped
The library is exported twice, and every module receives both views as special arguments:
| View | Use |
|---|---|
typed | The default in modules: typed helpers with checked arguments and better errors |
untyped | Plain functions for hot paths, where checking every call costs evaluation time |
Flakes expose it as lib.system and under legacyPackages. It is not under packages, because an attribute set of functions is not a derivation and would break nix flake show. Every example below was evaluated against this revision:
nix eval --json .#lib.x86_64-linux --apply 'L: with L; expression'
Enums and typed systems
An Enum is a set of named values, each with a value, a readable string form, and a switch that dispatches on it. This is how machines are described (see Fleet).
Group = data.Enum "Group" { Work = "work"; Personal = "personal"; Android = "android"; };Group.Work.value # "work"toString Group.Personal # "Group.Personal(personal)"Group.Android.switch { Work = 1; Personal = 2; Android = 3; } # 3Group.Android.switch { Work = 1; } # error: no case for Androiddata.SystemType.NixDarwin.switch { NixOS = "nixos-rebuild"; _ = "other"; } # "other"(data.Tagged [ data.WSL ] data.System.NixOS.X86).tags # [ { tag = "wsl"; } ]
A switch without a case for the value fails, unless it has an explicit _ fallback. Adding a platform therefore shows every place that has to learn about it.
Dispatch
dispatch picks an implementation by the argument's builtin type, dispatch.on by any function of the argument, and dispatch.def adds a default.
f = dispatch { int = x: x + 1; float = x: x - 1.0; string = x: "hello ${x}"; };f 3 # 4f 5.0 # 4.0f "world" # "hello world"f true # error: unsupported type boolg = dispatch.on (x: x.kind) { circle = s: "circle r=${toString s.r}"; rectangle = s: "${toString s.w}x${toString s.h}";};g { kind = "rectangle"; w = 3; h = 4; } # "3x4"h = dispatch.def (x: { unknown = x; }) { string = x: x + " world"; int = x: x + 1; };map h [ "hello" 1 2.0 ] # [ "hello world" 2 { unknown = 2.0; } ]
Variadic functions
Nix functions take one argument at a time, so a function cannot know when its caller has finished. A Variadic folds arguments into a state until a terminal argument, by default the marker ___:
Variadic.mkList 1 2 3 ___ # [ 1 2 3 ]Variadic.mkList 1 2 # still a function, waiting for more
A variadic is defined by an initial state, a handler that folds each argument in, a test for the terminal argument and a function that produces the result. Variadics compose, and ordered keyword builders are built from them.
Solos and collections
Nix attribute sets are unordered. Where order matters, as with fields, options or table columns, the library uses solos: lists of single-key attribute sets.
solos { a = 1; b = 2; } # [ { a = 1; } { b = 2; } ]soloNames [ { x = 1; } { y = 2; } ] # [ "x" "y" ]
Around them sit deep traversals (deepMap, deepFilter, deepConcatMap and their conditional forms), folds with explicit specs, recursiveMergeAttrsList (which assembles the module trees), lazy lists and attribute sets, trees, and list helpers such as insertAt, setAt and takeWhile.
Errors, logging and strings
tryOr (throw "boom") "fallback" # "fallback"_b_ '' hello indented'' # "hello\n indented"joinLines [ "a" "b" ] # "a\nb"pluralise 3 "machine" "s" # "machines"
_b_ dedents block strings, and its siblings print values readably for messages. log provides levelled tracing and log.while context, so an evaluation error says what was being done, not only where. errors builds structured failures, and try, tryOr and tryApply recover from them.
Inline tests
Library files carry their own tests in a _tests attribute, next to the code they test, written with expect:
_tests = with tests; suite { dispatch = { int = expect.eq (f 3) 4; string = expect.eq (f "world") "hello world"; };};
Module trees use the same machinery: withMergedSuites merges each module's tests into one suite, and removeTests strips them before the modules are used, which is the difference between the flake's testedModules and modules outputs. See Development for how to run them.
An experimental type system
The most ambitious part of the library is a runtime type system with classes, typed fields, methods, type variables and casts. Its types are themselves values of a type, and it is built as a tower of universes. A deliberately limited U_0 bootstraps U_1, each level derives the next, and by U_4 the system is fixed: its Type is its own type, and deriving another level gives back the same system.
U_0. Some plain-value types and examples in older documentation do not evaluate there. The enums, dispatch and helpers above do not depend on it.Machines and desktops as data
Several library files describe hardware and desktops as typed data that modules render into configuration:
| File | Describes |
|---|---|
data.nix | Architectures, system types, systems, tags and named systems |
display.nix | Panels and outputs: names, resolutions, refresh rates, scales |
wm.nix | Window-manager bindings and workspace stepping shared by Hyprland, Sway and i3 |
colors.nix, font.nix | Colour schemes and fonts applied across terminals and bars |
fan.nix, disk.nix | Fan curves for handheld PCs, and filesystem mounts for Windows partitions and SD cards |
script-utils/ | The framework that generates cltv (see CLI) |
Module option helpers come from here too: mkEnable, mkDefaultEnable, mkDefaultOption and Opt.enable, with nested and default variants, keep hundreds of option declarations to one line each.
nix-reflect
The parser, evaluator and reflection library (Nix written in Nix, with an evaluation monad) moved to its own public flake, nix-reflect. collective-lib re-exports it as nix-reflect. The two flakes take each other as inputs, and the cycle is cut with an empty follows.