ADR-304: Reflection Is Data, Not a Syntax Tree — T::fields Is a List a Bound Makes Exist, and the Loop Over It Is Unrolled Once per Type Used

Status: 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)

1. Question

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.

2. How others do it

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.

3. Options, weighed

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.

4. Decision

D1 — 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).

D2 — A type’s shape is reached as members, and the bound is what makes them exist.

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.

D3 — A bound, not an untyped parameter.

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.

D4 — 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.

D5 — One loop, and it needs no second word.

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.

D6 — The loop is unrolled once per type the function was used with, and the generic original is not emitted.

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).

D7 — The body is checked per unrolled iteration, and that is a build-time cost only.

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`

D8 — Each turn is checked with its field bound, and a message is said once.

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.

D9 — What cannot be read is answered by a report, not by syntax.

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.

D10 — --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.

5. Consequences

6. What this does not decide

This site is open source. Improve this page.