T::fields Is a List a Bound Makes Exist, and the Loop Over It Is Unrolled Once per Type UsedStatus: Accepted
Date: October 3, 2026
History: the records this one replaces are archived in docs/history/adr; read them only for how the decision was reached
Answers: what Part II 10.3 needs to generate code from a type’s shape -
describing, serialising or mapping a struct - once the language has comptime;
and what T::fields is, how a loop over it is lowered and checked, and how a
reader sees what it became
(#174)
Related:
ADR-287 (comptime, the one build-time mechanism this uses),
ADR-295 (a bound names a declared trait and is answered from it; NK1164 at the call),
ADR-217 D1 (T::variants, the enum half of a shape walk),
ADR-298 D6-D7 (macro and quote are names, and what the refusal tells a reader who writes one),
ADR-299 D5 (no expression capture),
ADR-296 D3 (one spelling at build time and run time),
ADR-288 (the emitter knows no types, so the checker hands it the answer),
ADR-024 D1-D2 (? fits everything; a refusal needs both sides known)
Part II 10.3 names a real need: a function such as describe, written once,
that prints, serialises or maps any struct by walking its fields. What does
the language need for it, given that it already has comptime - a macro system,
or less? And once the answer is less: what is a type’s shape as a value, how
is a loop over it emitted, how is its body checked when each field has a
different type, and how does a reader see what a call turned into?
Without an answer, 10.3 either describes three constructs (macro, quote,
with-attached macros) nothing builds, or nothing at all.
| Language / tool | Its answer | What it costs there | Source |
|---|---|---|---|
| Zig | No macro system. Types are values at build time, @typeInfo(T) hands back a type’s shape as ordinary data, and inline for walks a build-time list; @field(v, name) reads a field |
anytype parameters: the signature says nothing, so a type that does not fit fails deep in the body, with the message pointing into someone else’s code |
from memory, not checked |
| D | Build-time evaluation, static if, static foreach, __traits reflection - close to Zig - and on top mixin, which compiles a string built at build time as source |
the best-known readability complaint about the language: code assembled by concatenation, messages pointing at text nobody wrote | from memory, not checked |
| C++ templates (before concepts) | an unconstrained type parameter, checked per instantiation | twenty years of errors reported inside the template rather than at the call | from memory, not checked |
| Rust | procedural macros over tokens, derive |
code generated behind the reader’s back; a trait named Struct does not exist below, so a bound by that name cannot travel into generated Rust |
from memory, not checked |
What transfers is Zig’s: one mechanism, the shape as data, a loop at build time.
What does not is how Zig reaches the shape (a builtin returning a union to index
into) and anytype: this language has bounds answered from declarations
(ADR-295), which do that navigation and move a failure to the
call. D’s mixin is Part III C.1’s class - a
message about text the program never wrote - and is refused on principle.
| Option | What a developer writes | Why it lost / won |
|---|---|---|
A - a macro system (macro, quote { … }, a macro attached to a declaration) |
macro describe(t) { quote { … } } |
three constructs for one need that comptime and a loop already meet; code generated behind the reader’s back |
B - a string mixin (D) |
mixin(build_source(T)) |
messages point at text nobody wrote: Part III C.1’s class |
| C - a builtin and an untyped parameter (Zig) | fn describe(v: anytype) over @typeInfo(T).Struct.fields |
a builtin and a union tag to navigate; a wrong type fails inside the body, not at the call |
D - a second loop keyword (inline for) |
inline for f in T::fields |
the word adds nothing: a loop over something known while the program is built cannot be anything but unrolled |
| E - syntax to show what is generated | an annotation or block marking generated code | the project already answers what did the compiler decide with a report (--overlaps, --sharing, --tethers, --trust) |
F - the shape as members under a bound, one for, a report |
fn describe[T: Struct](value: T) { for field in T::fields { … } } |
chosen: no new construct, the failure that can move to the call does, nothing remains at run time |
Within F, two lowerings were possible: emit the generic function and walk a run-time descriptor, or emit one copy per type used and no generic original. The second leaves at run time only what a person would have written by hand, which is the promise of D7.
comptime is the only mechanism. There is no macro system.The whole describe family - printing a struct, serialising one, mapping one
onto columns - needs a type’s shape as data and a loop that runs while the
program is built, and nothing else. There is no macro, no code written as
data, and nothing attached to a declaration. macro and quote are ordinary
names; a program that writes one where nothing declares it is told Nikaia has
no macros; generating code from a type’s shape is comptime and a bound
(ADR-298 D7).
fn describe[T: Struct](value: T) {
for field in T::fields {
println(f"{field.name} = {field.of(value)}")
}
}
T: Struct is what makes T::fields exist, as any bound makes a method exist
(ADR-295), and T: Enum makes T::variants exist
(ADR-217 D1). Both are answered from the declaration, not from
an impl. The trait system does the navigation Zig does with a union tag, so
there is no builtin; reading a field is field.of(value), a method on the
reflected field, not a piece of syntax. The separator is ::, as for every
qualified name. T::fields without a Struct bound, or T::variants without
an Enum bound, is NK1171 naming the bound.
The bound moves one whole class of failure to the call: you passed
something that is not a struct is answered once, where the call is
(NK1164). What stays inside the body is only what is genuinely per-field
(D7). The per-instantiation check is not removed - nothing can remove it - but
it no longer carries the failure a signature could have stated.
T::fields is a list of reflected fields, and a function walks a shape when its body says so.Changed by ADR-329 D1-D2: a reflected field also answers .ty and .doc; ADR-330 D5: .ty is the field’s type; ADR-331 D5-D6: .default, .attribute(X), .attributes(X).
A reflected field answers .name, the field’s own name as text, and
.of(value), what that field holds on this value. Those two are the whole
of it; any other member is NK1180.
A function walks a shape when its body writes T::fields, not when its
bound allows it: fn tell[T: Struct](v: T) that never writes T::fields is an
ordinary generic function, generic in the language below and emitted once. The
bound says a shape may be asked for; the body says whether it is.
T::fields is known while the program is built, so a for over it cannot be
anything but unrolled - there is no run-time reading to rule out. The loop
means what a loop always means, walk these elements; only the stage
differs. This is not the fault ADR-296 D3 removed, where an
operand changed meaning with its position: here the meaning is the same and
only the time is earlier.
Changed by ADR-330 D1: the type may also be the one a call writes where the parameter list holds it.
What is emitted is one copy of the function per type argument a call gave it
(describe__User), with the loop replaced by its turns: field.name is a
literal and field.of(value) is value.name. A copy has no type parameter
left, so it is an ordinary function in the language below; the lint
annotation that keeps Rust from asking the author to rename a name this
compiler chose rides with it (Part III C.1).
field.of(value) has a different type in each iteration, so there is no single
type to check the body against once.
The cost that matters is the message: a body wrong for one field is wrong at one unrolled copy, on a line that is right for the others, so the diagnostic names the unrolling:
error: `println` cannot format a `Vec[u8]`
--> describe.nika:3:9
= unrolling `T::fields` for `User`, at field `avatar`
The body is walked once with nothing known and once per turn with the field
bound; every message from a turn carries the unrolling … at field … line of
D7.
field.of(value) answers ?, which fits everything
(ADR-024 D1); a refusal from that walk would be a refusal about
every turn at once.Every build-time system shares one readability problem: a reader cannot see
what a function becomes for a given type without unrolling it in their head.
The answer here is not new syntax but the project’s existing one: the
compiler’s decisions can be asked about, as --overlaps, --sharing,
--tethers and --trust answer for theirs.
--comptime prints what was unrolled, once for the program.--comptime (on nikaia build and nikaia lower) prints, for the types
actually used, what each shape walk was unrolled into - the information D7’s
diagnostic carries, offered on demand instead of on failure. It is printed
once for the program, not once per file, because an unrolling is a fact
about a call and a call may stand in a different file from the function it
names. A shape walk nobody calls has a line of its own: no copy is written
for it, so only the report can say it exists.
describe.NK1164 at a call whose type does not
answer the bound, NK1171 for T::fields or T::variants without its
bound, NK1180 for a member a reflected field or variant does not have.Struct or Enum, so a copy carries no type parameter at all
(D6). A program that declares its own trait Struct keeps it, with the
impl that answers it.describe is a free function; a
[T: Struct] method would need the instantiation read off a receiver, which
is a different lookup.match.