Aspect-oriented composition types for Nix module systems.
A pure type library: no resolve, no pipeline, no framework. It provides the structural types for defining aspects — composable configuration units with identity, includes, and class-separated content. Consumers (like den) bring their own evaluation pipeline.
Dependency class: Class D (nixpkgs-lib-tethered). gen-aspects depends on nixpkgs lib (lib.types + evalModules) and on gen-schema. It is not nixpkgs-lib-free — the module-system machinery it builds on is nixpkgs lib.types.
- Terminology
- Overview
- Gen Ecosystem
- Usage
- Core Concepts
- Schema Integration
- Flat Registry
- API Reference
- Demo
- Testing
- Theoretical Foundations
| Term | Definition |
|---|---|
| Traits | The aspect type — one type, dispatch in merge (Palmer 2024) |
| Classes | Output targets (NixOS, darwin, homeManager module systems) |
| Collections | Named data aggregation (aspect keys matching registered collection names) |
| Edges | includes (forward I) — the one core structural edge, declared inline on each aspect. neededBy (reverse I) — a consumer-declared, predicate-based reverse reference; its semantics live in the consumer's dispatch layer, not in these types. |
| Constraints | Pruning rules: meta.guard, meta.drop, meta.substitute |
gen-aspects gives you the types, not a framework. An aspect is a submodule carrying structural identity (name, key, meta, includes) plus freeform, class-separated content. You register your target module systems as classes (nixos, homeManager, darwin); each class becomes a clean deferredModule option so content stays free of the structural keys.
One flat type (aspectType) dispatches by value shape at merge time (Palmer 2024): attrsets and module functions become aspect submodules, context-dependent guard functions are wrapped as inspectable, tagged functors, and primitives pass through unchanged. The library computes stable identity keys and, via flatten, a flat path-keyed registry suitable for graph queries.
Everything downstream — evaluation, scheduling, conflict resolution, dispatch policy — is the consumer's job. gen-aspects supplies the type surface and the identity keys; the pipeline lives in gen-resolve / gen-dispatch / den.
| Library | Role |
|---|---|
| gen-prelude | Pure nixpkgs-lib-free utility base (builtins re-exports + vendored lib utils) |
| gen-algebra | Pure primitives (record, search monad, either, intensional identity) |
| gen-types | Clean-room MIT structural type checker (leaf/poly checkers; verify: v → null|err) |
| gen-merge | Byte-mode module merge engine (evalModuleTree, byte-identical to nixpkgs lib.evalModules over the priority subset) |
| gen-schema | Typed registries (kinds, instances, collections, refs); re-hosted on gen-merge |
| gen-aspects | This lib — Aspect type system (traits, classification, dispatch); re-hosted on gen-merge |
| gen-scope | HOAG scope-graph evaluator (demand-driven, _eval memoization, circular attributes) |
| gen-graph | Accessor-based graph query combinators (traversal, condensation, phaseOrder) |
| gen-select | Selector algebra (pattern matching over graph positions) |
| gen-bind | Module binding (inject external args into NixOS modules) |
| gen-dispatch | Relational rule dispatch STEP (stratified phases, conflict resolution) |
| gen-resolve | Demand-driven RAG evaluator over scope graphs (attribute schedule + convergence loop) |
| gen-rebuild | Pure-Nix incremental rebuilder (change propagation, AFFECTED set) |
| gen-vars | Pure-Nix vars/secrets (den-agnostic) |
| gen-flake | The nixpkgs boundary — compose purely, inject resolved values, build NixOS systems (value-injection) |
The flake exposes a single .lib value output (no __functor); nixpkgs lib and gen-schema are wired in by the flake.
# flake.nix
{
inputs.gen-aspects.url = "github:sini/gen-aspects";
outputs = { gen-aspects, ... }: {
# bind the value directly — lib + gen-schema are wired in by the flake
lib.aspects = gen-aspects.lib;
};
}default.nix takes lib and auto-fetches gen-schema from the pinned flake.lock:
aspects = import gen-aspects { inherit lib; };let
aspects = gen-aspects.lib;
eval = lib.evalModules {
modules = [{
options.aspects = lib.mkOption {
type = aspects.aspectsType {
classes = { nixos = {}; homeManager = {}; };
};
default = {};
};
config.aspects.networking = {
nixos.networking.hostName = "myhost";
nixos.networking.firewall.enable = true;
};
config.aspects.desktop = {
includes = [ eval.config.aspects.fonts ];
homeManager.programs.alacritty.enable = true;
};
config.aspects.fonts = {
nixos.fonts.packages = [ pkgs.noto-fonts ];
};
}];
};
in
eval.config.aspects.networking.nixos
# => { imports = [{ networking.hostName = "myhost"; ... }]; }
# Clean deferredModule — no structural keys (name, includes, meta, etc.)Aspects are submodules with structural identity (name, key, meta, includes) and freeform content. Every non-structural, non-class key becomes a nested aspect with its own identity.
Classes are registered content buckets (nixos, homeManager, darwin). When registered via cnf.classes, class keys become explicit deferredModule options — clean content with no structural keys injected. This is the module system's own option/freeform separation, not a custom dispatch mechanism.
Guard functions like { host, ... }: { nixos = ...; } are context-dependent aspects that should not be evaluated eagerly. They're detected via canTake (all required args must be known module args) and wrapped via functionTo for pipeline resolution later.
Module functions like { config, ... }: { ... } or { aspect, ... }: { ... } are evaluated immediately by the submodule — they have access to _module.args.aspect (self-reference) and standard module args.
gen-aspects depends on gen-schema and provides mkAspectSchema to bridge aspect types with gen-schema's kind-level infrastructure (collections, introspection, schema extensions).
aspects = gen-aspects.lib;
schema = aspects.mkAspectSchema cnf;mkAspectSchema cnf returns:
| Field | Description |
|---|---|
schemaOption |
gen-schema option wrapping aspectType as the custom entry type |
mkAspectOption { providerPrefix? } |
Declares options.aspects with lazyAttrsOf aspectType |
mkAspectModule { providerPrefix? } |
NixOS module declaring both options.aspects and options.schema, lazily threading schema-declared options into every aspect instance |
mkNamespaceType { } |
Submodule type for namespace composition — includes schema, classes, and freeform aspect content |
aspectType |
Re-exported aspect type |
identity |
Bundled identity functions (aspectPath, pathKey, key, isMeaningfulName) |
canTake |
Re-exported function arg introspection |
mkIsModuleFn |
Re-exported module function predicate |
Schema-declared options propagate to aspect instances via mkAspectModule. When a schema kind entry declares options (e.g., priority, tier), those options become available on every aspect:
{ config, ... }:
{
imports = [ (schema.mkAspectModule { }) ];
# Collections and extensions declared on the schema kind
schema.aspect = {
settings = { }; # collection
tags = { }; # collection
# options.priority = lib.mkOption { ... }; # schema extension
};
# Every aspect now has access to schema-declared options
aspects.networking.priority = 10;
}mkAspectModule lazily injects config.schema.aspect.__defsModule into each aspect's aspectModules, so schema extensions are available without manual wiring.
The flatten function walks the recursive aspect tree and produces a flat attrset keyed by path identity:
aspects = gen-aspects.lib;
flat = aspects.flatten eval.config.aspects;
# => { "networking" = ...; "networking/firewall" = ...; }Entries are the aspect values unchanged — flatten does not inject any fields. Parent relationships are implicit in the path key: "networking/firewall" → parent is "networking". Guard functions (__isWrappedFn) are included as entries but not recursed into.
Detection is structural rather than relying on a hardcoded key list:
- Nested aspects are attrsets with a
namefield (fromaspectSubmodule) - Class content (
deferredModule) lacksnameand is skipped - Primitives (strings, lists) are skipped
The flat registry enables gen-graph and gen-select queries over the aspect tree. Parent accessors derive from the key:
parentOf = id:
let parts = lib.splitString "/" id;
in if builtins.length parts <= 1 then null
else lib.concatStringsSep "/" (lib.init parts);The .lib value exposes eighteen top-level names: the four aspect types, the four identity/introspection utilities plus guardKey, the four schema-and-registry entry points, and the five-name guard-predicate vocabulary (mkGuardVocab, applyGuard, toArgData, pred, guard).
aspects = gen-aspects.lib;-
aspectsType cnf— top-level container. Submodule withfreeformType = lazyAttrsOf (aspectType cnf)and fixpoint (_module.args.aspects = config). -
aspectSubmodule cnf— aspect entry. Submodule with structural options (name,description,key,meta,includes), explicitdeferredModuleoptions per registered class, and freeform for nested aspects. -
aspectType cnf— Palmer flat dispatch. One type, dispatch in merge. Attrsets and module functions →aspectSubmodule. Guard functions →functionTowrapper. Primitives → passthrough. -
aspectOrFn cnf—either aspectType aspectSubmodule. Recursion-safe binding forincludesand nested aspect positions.
aspectsType {
# Registered class names → explicit deferredModule options (clean content)
classes = { nixos = {}; homeManager = {}; };
# Known module args for module/guard function detection
# Default: { lib, config, options, pkgs, modulesPath, aspect }
moduleArgs = { lib = true; config = true; /* ... */ };
# Additional NixOS modules imported into every aspect entry
# Use for pipeline-specific options (excludes, policies, etc.)
aspectModules = [
({ config, ... }: {
options.excludes = lib.mkOption { default = []; type = lib.types.listOf lib.types.str; };
})
];
# List of NixOS modules imported into each aspect's `meta` submodule.
# Allows consumers to declare typed meta options (e.g., `meta.guard`,
# `meta.priority`) alongside the freeform attrs.
metaModules = [ ];
}canTake— function arg introspection.canTake.upTo params fnchecks if all required args offnare satisfiable byparams.mkIsModuleFn cnf—canTake.upTo (cnf.moduleArgs or defaults). Returns a predicate that classifies functions as module fns or guard fns.key,aspectPath,pathKey,isMeaningfulName,guardKey— identity computation frommeta+name.keyroutes three ways: static aspects (viameta.aspect-chain), wrapped guard functions (viameta.loc), and defunctionalized guard records (__guard→guardKey, a site-independent structural key over the predicate + first-order body).
mkAspectSchema cnf— bridges aspect types to gen-schema kind-level infrastructure. ReturnsschemaOption,mkAspectOption,mkAspectModule,mkNamespaceType, plus re-exports (aspectType,identity,canTake,mkIsModuleFn). See Schema Integration.flatten aspects— walks the recursive aspect tree into a flat attrset keyed bypathidentity ("parent/child"), structurally detecting nested aspects vs class content. See Flat Registry.
The examples/demo/ directory exercises eight gen libraries together: gen-algebra, gen-schema, gen-aspects, gen-graph, gen-scope, gen-select, gen-bind, and gen-dispatch. It demonstrates entities, aspects, namespaces, policies, queries, bindings, composition, and settings in a single integrated flake.
nix shell nixpkgs#nix-unit -c nix-unit \
--override-input target . \
--flake './ci#.tests'109 tests across 17 suites (verified 109/109 successful via nix-unit): can-take, class-content, extensions, flat-registry, freeform-dispatch, guard, guard-identity, identity, includes, lazy-classification, meta-modules, multi-def, multi-def-identity, nested-aspects, parametric, reserved-keys, and schema-integration — covering class content cleanliness, nested aspect identity, includes fixpoint, module vs guard function dispatch, the guard predicate vocabulary + defunctionalized identity (mkGuardVocab/applyGuard/guardKey), lazy classification, parametric aspects, multi-def merging, reserved keys, primitive passthrough, deep nesting, extensions, meta modules, canTake introspection, schema integration, and the flat registry.
| Paper | Relationship | Mechanism |
|---|---|---|
| Palmer et al. (2024) "Intensional Functions" | Implements | Flat dispatch via one type in merge §2, identity §2.2; identity keys enable consumer-side dedup |
| Lorenzen et al. (2025) "First-Order Laziness" | Informed by | deferredModule inspectable before forcing (via Nix native laziness, not Lorenzen's mechanism) §1-2.3 |
| Reynolds (1972) "Definitional Interpreters" · Danvy & Nielsen (2001) "Defunctionalization at Work" | Implements | §6 "Elimination of Higher-Order Functions" for the guard predicate vocabulary (mkGuardVocab/pred/applyGuard/guardKey, obligations O1–O7): predicates are first-order data dispatched by one global applyGuard, keyed by a site-independent guardKey. Raw { host, … }: closures remain the non-defunctionalized escape hatch (functionTo) |
Palmer et al. (2024) "Intensional Functions" — One type dispatches by value shape in merge (§2). Guard functions are defunctionalized as callable first-order data with inspectable args (§5.1). Identity keys enable consumer-side diamond dedup (Lemma 5.12 + Theorem 1, closure consistency); gen-aspects supplies the keys, the dedup lives in the consumer.
Lorenzen et al. (2025) "First-Order Laziness" (informed by) — Class content as deferredModule is inspectable before forcing, evaluated only when the consuming NixOS evaluation imports it (§1-2.3). This property comes from Nix native laziness plus nixpkgs deferredModule, NOT from Lorenzen's mechanism (first-order named constructors, defunctionalized deferred operations, in-place memoization). The citation is provenance for the laziness idea, not an implementation of the paper.
Reynolds (1972) "Definitional Interpreters" + Danvy & Nielsen (2001) "Defunctionalization at Work" — gen-aspects ships a closed guard-predicate vocabulary (lib/guard.nix) that is a genuine §6 defunctionalization for the guard function-space: a guard is a { __guard; pred; body } record whose predicate is pure first-order data (pred.host/class/user/tagEq/eq/all/any/always, type-tagged via toArgData), dispatched by a single global applyGuard (case-analysis on the predicate tag — Reynolds' apply), and identified by a site-independent guardKey = H(pred, bodyKey) (the constructor tag replaces source position). The O1–O8 obligation checklist is Danvy & Nielsen's formalization of Reynolds §6. Honest boundary: arbitrary { host, … }: { … } closures cannot be auto-defunctionalized in pure Nix (function equality is undecidable — the closure wall), so they remain a non-defunctionalized escape hatch via functionTo (a tagged, still-callable functor with a source-position key); applyGuard handles both. guardKey content-hashes a first-order body (enabling dedup); an opaque body (a closure / deferredModule) falls back to source position — sound (no false merge), just no cross-site dedup.
MIT — see LICENSE.