ADR-088: Reflection Is Data, Not a Syntax Tree — and macro, quote and with Are Reserved Words That Are Not Constructs

Status: Superseded by ADR-304 — its D1-D3 are D1-D3 there, D4 is D5, D5 is D7 and D6 is D9; D7 is not carried (the words it named are decided by ADR-298 D6-D7 and ADR-118) Date: September 15, 2026 Supersedes: the design of Part II 10.3, which described a macro system with three constructs. The problem it names — generating code from a type’s shape — stays, and is solved here without any of them Related: ADR-287 and ADR-287 (comptime, which is the only mechanism here), ADR-295 (a trait is declared and a bound is answered from it — the piece that makes this readable), ADR-298 D1 and ADR-276 D9 (reserving a word while it is free, and why un-reserving is the free direction), ADR-079 §3 (the build-time evaluator that must loop, needed for this too)


1. Context

Part II 10.3 describes a macro system: macro declares a build-time function over program pieces, quote { … } writes code as data, and with attaches a macro to a declaration. Its worked example generates a describe method over a struct’s fields.

Three words, none of them in the compiler and none reserved. Before reserving them, the question was put the other way round: what does this language actually need, given that it now has comptime?

The answer came from looking at how the two languages best known for compile-time evaluation do it.

Zig has no macro system at all — no quote, no attributes, one mechanism. Its version of 10.3’s example is an ordinary function:

fn describe(v: anytype) void {
    inline for (@typeInfo(@TypeOf(v)).Struct.fields) |f| {
        std.debug.print("{s} = {any}\n", .{ f.name, @field(v, f.name) });
    }
}

Three things carry it: types are values at build time, @typeInfo(T) hands back a type’s shape as ordinary data, and inline for walks a build-time-known list. The absence of macros is deliberate there, on a readability argument — you can see what code does by reading it, because nothing is generated behind your back.

D went the other way, and shows what not to take. It has build-time evaluation of ordinary functions, static if, static foreach and reflection through __traits, all of which is close to Zig — and on top, mixin, which compiles a string built at build time as if it were source. Maximum power, and the best-known readability complaint about the language: code assembled by concatenation, with messages pointing at text nobody wrote. That last part is Part III C.1’s class, which this compiler refuses on principle.

2. Decisions

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 no construct that does not already exist or is not already planned. It needs a type’s shape as data, and a loop that runs at build time.

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

Not through a builtin returning a variant to index into. In Nikaia:

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, the same way any bound makes a method exist (ADR-295). A T: Enum would have T.variants. The trait system does the navigation Zig does with a union tag, so there is no @typeInfo, no .Struct., and no builtin at all — and @field(v, f.name) is field.of(value), a method on the reflected field rather than a piece of syntax.

D3 — A bound, not an untyped parameter.

Zig’s anytype is the most-named weakness of its build-time system: the signature says nothing, so a caller learns that its type does not fit only when something deep in the body fails to compile, with the message pointing into somebody else’s code. It is C++’s pre-concepts problem.

The bound does not remove the per-instantiation checking of D5 — nothing can — but it moves one whole class of failure to the call site: you passed something that is not a struct is answered once, where the call is. What stays inside is only what is genuinely per-field.

D4 — One loop, and the reason it needs no second word is a derivation rather than a convention.

Zig spells for and inline for. Here, T.fields is known at build time, so a loop over it cannot be anything but unrolled — there is no run-time reading to rule out.

This is not the fault ADR-296 removed, and the difference is worth stating because the two look alike. There, the operand changed meaning with its position — a file’s name in one place, the text itself in the other. Here the loop means exactly what it always means, walk these elements; only the stage differs. Same meaning, earlier time, is the property this language wants.

D5 — 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 not the seconds, it is the message. A body that is wrong for one field is wrong at one unrolled copy, on a line that is correct for the others — so the diagnostic must name the unrolling:

error: `println` cannot format a `Vec[u8]`
  --> describe.nika:3:9
     = unrolling `T.fields` for `User`, at field `avatar`

Without that line this is the error class C++ templates carried for twenty years.

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

Every build-time system shares one readability problem: you cannot see what a function becomes for a given type without unrolling it in your head. The usual answer is to invent syntax. This project already has the other one — --overlaps, --sharing, --trust, each with the same sentence behind it: the refusals are the compiler’s own, and that is only fair if they can be asked about.

So: --comptime prints what was unrolled, for the types actually used. It is the same information D5’s diagnostic carries, offered on demand instead of on failure.

D7 — macro and quote are names, and with is reserved for a construct of its own.

The language does not want a macro system, and a reader arriving from one who writes macro or quote is told so by the refusal a name nothing declares gets — Nikaia has no macros; generating code from a type’s shape is comptime and a bound (ADR-298 D7). Neither word is reserved. with is, for a construct unrelated to this record: a copy of a value with named fields changed, which has a record of its own.

The list goes from 35 to 36.

3. The one thing this does not cover

Capturing an expression. Reflection describes types. A LINQ-shaped query — from u in users where u.age > 18 select u.name — needs the expression u.age > 18 as a tree, for a provider to translate. Zig cannot do that at all, and D only through the mixin route D1 rejects.

So that is the single capability that would genuinely be new, it is needed by exactly one use case, and it is not decided here. What this record does is make sure it is the only thing left on the list rather than one of four.

4. Consequences

Part II 10.3 is rewritten rather than annotated. Its example stays and becomes an ordinary function; its three constructs go.

Three words are reserved for constructs the language expects not to have, which is unusual enough to say out loud. const is the precedent from yesterday (ADR-287 D2) and the reason is the same one.

The build-time evaluator is now wanted by three records — ADR-079 for a table, ADR-287 D6 for a call, and this one for a loop over fields. It is one piece of work and it is the gate for all three.

5. What is built

D7, from the day this was written. macro, quote and with are the 36th, 37th and 38th reserved words, so a program may not name anything one of them, and the parse error says which word and that it is reserved.

D2’s bound and D3, since 0.0.120. [T: Struct] and [T: Enum] are bounds this compiler answers, and what answers them is the declaration rather than an impl — which is D2’s point and the reason they could not be ordinary traits. NK1135 used to refuse Struct as a trait nothing declares, which was true and was not the answer.

Not built: D1’s whole family, D4, D5 and D6. T::fields is NK1171, which says that this section is specified and that the compiler does not have it — and which half of it is built — rather than sending a reader looking for a spelling that does not exist. What is left is the shape as a value, the unrolled loop, the per-iteration check with the field named in the diagnostic, and --comptime.

This site is open source. Improve this page.