Skip to content

Rationale · essay 08

Comptime and reflection

rationale.md · 82 lines · 3 min read

The redefinition: metaprogramming is a library, not magic. Comptime is pure code running in the compiler; reflection has no builtin at all.

Generating code from types (serialize, hash, derive) needs some form of metaprogramming. The historical choices: macros (C, text substitution), procedural macros (Rust, a second language), or a handful of builtins (@sizeOf, @typeName, in Zig). Each one adds a way of thinking separate from the language.

Comptime (Zig style) is simply “evaluate this at compile time”: it applies to a binding, a block, a loop, a match and an fn. type is a first-class value: a generic is a function that receives and returns type, executed by the compiler. There is no separate language: it is the same code, running earlier.

Reflection has zero builtins. There is no @type_name/@size_of in scope. Everything lives in the compiler package: reflect(T) (the structure of the type, matched with comptime match reflect(T).kind), fail(msg) (aborts compilation with a message). That is how serialize/hash/json auto-derive, without you writing anything, and even so none of that is a builtin.

And the comptime match reflect(T).kind is exhaustive: the compiler demands that you handle all the kinds (or a _/fail). A non-serializable type does not compile: the arm Ptr | ManyPtr | Channel | Function | RawPtr => fail(...) refuses it to its face, instead of generating garbage.

The root rule is non-poisoning taken to the limit: the global scope carries no function at all. Each builtin would be a poisoner of the namespace: it conflicts with user names, pollutes the global, and makes you memorize “is it @size_of or size_of?”. Moving everything to reflect(...)/fail(...) keeps the namespace clean: metaprogramming is a library you import, not embedded magic.

Why is comptime safe to exist? Because what runs at compile time is almost forced by the architecture to be the pure and deterministic core of the language, without IO, without processes, without channels, without syscalls. Comptime never reads a runtime value. That is what lets the compiler optimize aggressively and guarantees reproducibility, and it is why the transient scope exists (mut/lambdas are transient expressions, tied to their place, without leaking from comptime to runtime).

The exhaustiveness of match reflect.kind is not a detail: it is the application of the same principle as the closed-domain match (ch. 07) to derivation. And it is a lesson learned the hard way: the absence of an arm (Enum missing, then String missing) bit the design more than once, and the rule “every match reflect.kind covers the entire set” became a permanent check.

  • Macros (C). Text substitution: it breaks lexing and structure, and you debug the macro as a separate language. Comptime is real semantic execution, not text.
  • Scattered builtins (@sizeOf/@typeName, Zig). Each one pollutes the global and becomes “which builtin is which?”. Everything under reflect(...) is a namespace, not a vocabulary to memorize.
  • Implicit traits/derives (Rust). Without an exhaustive match, you discover that a type “almost” satisfies three layers of compilation below, with a confusing error. Derivation by reflect + exhaustive match gives the error at the boundary, clear.
  • Letting comptime values leak into runtime. It would break determinism and blind the optimizations; the transient scope prevents it.

The C macro that did not expand right because of a parenthesis and you debug blindly. The new Zig programmer stuck on “what is the reflection function again?”, because there are twenty builtins. The Rust derive that fails deep in the instantiation with a message that does not point to your code. Each one is metaprogramming that became a separate way of thinking, with its own rules and its own surprises.

Metaprogramming is a library, not magic. Comptime is your code running in the compiler, pure and deterministic; reflect(T) asks “what type is this?” with no builtin; the match over the kind is exhaustive, so the compiler forces you to handle everything.

Zero builtins costs verbosity: you always write reflect(T).kind instead of a short @kind. And comptime being pure (no IO, no reading runtime) limits what it can do, in exchange for total reproducibility. The bet is the usual one: a bit more text, in the name of a clean namespace and of a sharp boundary between what runs at build and what runs in the program.

Next: 09 · Strings