schemaLib.ref "kind" supports string-to-instance coercion for scalar fields. Assigning host = "igloo" on a ref "host" field resolves to config.hosts.igloo via an apply function injected by mkRefBindingModules.
This breaks for listOf (ref "kind"). The apply function receives the entire list, checks builtins.isString val, gets false (it's a list), and passes it through unchanged. Individual string elements never get coerced. Same gap exists for nullOr (ref "kind") in the deferred path (the direct-mode nullOr test uses nullOr (ref instances), not nullOr (ref "kind")).
Consumers hit this in practice: nest-traits declares needs = listOf (ref "trait") and wants needs = [ "nginx" "firewall" ] to resolve each string to a trait instance.
schema kind declares: options.host = mkOption { type = ref "host"; };
options.needs = mkOption { type = listOf (ref "trait"); };
findRefFields scans evaluated options via refsFromOptions
-> getRefKind traverses nestedTypes.elemType to find refKind
-> returns { host = "host"; needs = "trait"; } # correct detection
mkRefBindingModules builds per-field apply:
apply = val:
if builtins.isString val then registry.${val} # works for scalar
else val; # list falls through!
getRefKind already traverses listOf/nullOr wrappers correctly. The only gap is the apply function in mkRefBindingModules assuming scalar shape.
Make the apply function polymorphic over value shape. Extract a coerce helper and dispatch on the value's runtime type:
# In mkRefBindingModules, replace the apply lambda:
apply =
val:
let
coerce = v:
if builtins.isString v then
if registry ? ${v} then
registry.${v}
else
throw "ref field '${field}' on kind '${kind}': reference '${v}' not found in instance registry (available: ${builtins.concatStringsSep ", " (builtins.attrNames registry)})"
else
v;
in
if builtins.isList val then map coerce val
else if val == null then null
else coerce val;This works because:
applyruns post-merge, after the module system has type-checked the structure- For
ref "kind"(bare): val is string or attrset,coercehandles both - For
listOf (ref "kind"): val is a list,map coercehandles each element - For
nullOr (ref "kind"): val is null or string/attrset, both branches handled - For nested wrappers like
nullOr (listOf (ref "kind")): null passes through, list gets mapped - Instance values (attrsets) pass through
coerceunchanged
No changes needed to getRefKind, refsFromOptions, or findRefFields. The type detection already works; only the coercion apply was too narrow.
Current error doesn't list available keys. The fix adds (available: ...) to the error message, matching the pattern in mkCoercingRefType's error. This helps users diagnose typos.
nix/lib/instance.nix—mkRefBindingModulesapply function: ~10 lines changed
New test file templates/ci/tests/ref-listof.nix:
test-listof-ref-string-coercion—listOf (ref "kind")with string elements resolves each to instancetest-listof-ref-mixed-coercion— mix of string keys and direct instance values in same listtest-listof-ref-empty— empty list passes throughtest-nullor-deferred-ref-null—nullOr (ref "kind")deferred with null valuetest-nullor-deferred-ref-string—nullOr (ref "kind")deferred with string key
Existing tests must continue passing (scalar ref, nullable direct-mode ref, instance coercion, etc).
gen-schema only. No consumer changes. nest-traits can switch needs from listOf raw to listOf (ref "trait") after this lands.