macro, quote and with Are Reserved Words That Are Not ConstructsStatus: 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)
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.
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.
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.
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.
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.
field.of(value) has a different type in each iteration, so there is no single
type to check the body against once.
println calls, no loop, no dispatch.for x in xs over a run-time list is checked once, as always.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.
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.
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.
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.
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.
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.
NK1164 and a way out that is a
declaration: impl Struct for Op is not something anybody can write, so
offering it would be a way out that cannot be taken
(Part III C.2).std type,
a foreign one, a name no ledger classifies. What stays refused is what it has
read: the other shape, and the types Part I 2.2 offers.Struct, so one in
the generated file would come back as cannot find trait Struct in this
scope — Part III C.1’s class. A program that
declares trait Struct { … } keeps its own, and then the bound does travel,
with the impl that answers it.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.