Generics and comptime
Two layers
Section titled “Two layers”Generics and metaprogramming are one and the same underneath: comptime. There are two layers of use:
- Declarative layer: the
[...]syntax with explicit constraints. It is what most code touches, with clear reading and clean error. - Engine: raw comptime, that is, functions that compute types, compilation blocks, reflection. The power tool for when you need to generate a type, not just parameterize one.
The declarative layer desugars to the engine. You use the declarative one day to day and descend to the engine only when you need real metaprogramming.
type is a compile-time value
Section titled “type is a compile-time value”The basis is the same as Zig’s: at compile time, types are first-class values. There exists a type whose values are types. A generic is just a function that receives or returns type, executed by the compiler:
// what you write (declarative):decl List[T] { items: *T, len: usize }
// what it is, underneath (engine):comptime fn List(T: type) -> type { return decl { items: *T; len: usize }}decl Name[T] {...} is sugar for a comptime fn that returns type. List[int] is a compile-time call that produces a concrete type.
[...] is compile time, (...) is runtime
Section titled “[...] is compile time, (...) is runtime”The distinction is temporal: [...] holds the parameters resolved at compile time, and (...) holds the runtime ones. The question that decides where each parameter goes is a single one: “does the compiler need this value to generate the code?”
fn repeat[n: int](s: string) -> string // n: compile time; s: runtimefn zeros[N: usize]() -> [N]int // N dimensions the array at compile timefn add(a: int, b: int) -> int // all runtime; [...] omitted[...] accepts types and compile-time values ([n: int], [N: usize]), not just type. Each distinct value in [...] generates a specialized function in the binary. This is monomorphization, the same thing Rust and C++ do with generics, here exposed to any comptime value:
repeat[3]("ab") // the compiler materializes repeat__3, with the 3 nailed in // (the loop over n vanishes; it becomes 3 fixed concatenations) // then ("ab") enters the ready function, at runtimeType arguments are inferred where the values pin them. You may write them explicitly (id[int](5), Box[int]{5}, Tree[int].Leaf(5)), but where the arguments already fix the parameters the compiler recovers them: id(5) (from the call argument), Box{5} (from the field), and Tree.Leaf(5) (from the variant payload) all resolve T = int, and the inferred form produces the exact same specialization as the explicit one. Where nothing pins a parameter — a payload-free variant like Tree.Empty, or a parameter that appears only in the return type — you write it explicitly or let an annotation supply it (t: Tree[int] = Tree.Empty).
No partial type-argument application. The choice is all-or-nothing: either every type parameter is inferred from the arguments (no brackets), or every one is written explicitly in [...]. You cannot write some and let the rest be inferred — f[i32](p) for a two-parameter f[T, V] is rejected, you write f[T, V](p) (both) or f(p) (neither, when the arguments pin them). The reason is the language’s rule that you always read where a type came from: a mixed [i32] on a two-parameter function leaves the reader guessing which parameter it filled and where the other came from — the same discipline that forbids the ambient self in an interface default method (section 9). This is why the FFI mem.reinterpret[c.gcc.void, i32](a) (section 17) spells both, even though the source could be inferred from a.
Declared constraints, and the clean error that Zig does not have
Section titled “Declared constraints, and the clean error that Zig does not have”The most common criticism of Zig’s comptime is the implicit constraint (duck typing): you discover that a type does not fit when the instantiation blows up three layers deep, with a cryptic error. Here the constraint is declared, and the compiler checks at the instantiation boundary, erroring cleanly. There are two orthogonal axes:
Capability-based (“T needs to know how to do X”):
fn serialize[T + Serializable](val: T) -> []byteserialize[MyStruct](x)// ERROR at the call: MyStruct does not satisfy Serializable (missing 'serialize')// and not an error buried inside serializeWhitelist-based (“T can only be one of these types”; it is first-class, whereas Rust needs sealed traits as a workaround):
fn serialize[T: {i8, u16, bool}](val: T) -> []byteCombined:
fn process[T: {i8, u16} + Serializable](val: T) -> []byteIn all of them, the parameter is named (T), and the body uses that T. The rule is one symbol, one intent: : introduces a whitelist (list of types in braces {...}, that is, “T is one of these”), and + introduces an interface (capability, that is, “T implements this”). The two compose: T + Serializable (interface only), T: {i8, u16} (whitelist only), T: {i8, u16} + Serializable (both). Why two symbols and not just :? Because a name does not say what it is: T: Foo would be ambiguous between “T is the type Foo” and “T implements Foo”. With : (and {...}) it is always membership, and with + it is always implementation. The form is flat: no nestable operators (|, &, parentheses), a constraint never becomes type algebra. It is at most “a whitelist, and-or interfaces”.
comptime fn and comptime {}
Section titled “comptime fn and comptime {}”Three constructs, without overlap: [...]/(...) is the phase of the parameters, comptime fn is the function vanishing from the binary, and comptime {} is a compilation block:
| Construct | Goes into the binary? | What for |
|---|---|---|
fn f(...) |
yes | pure runtime |
fn f[...](...) |
yes (one copy per [...] value) |
runtime specialized at compile time |
comptime fn f(...) |
no | computing types/constants (the engine) |
comptime { ... } |
no (it is executed, not called) | running compilation logic inline |
A fn with [...] goes into the binary (the specialized copy runs at runtime); only the bracket parameters were resolved early. A comptime fn is the opposite: the whole body runs in the compiler and nothing is left. Inside a comptime fn everything is already compile time, so the [...]/(...) distinction does not apply in there, since ordinary parameters suffice. A comptime fn can only be called in a compile-time context (in a const, in a [...], inside another comptime fn or a comptime block).
The quadrant: runtime × compile time
Section titled “The quadrant: runtime × compile time”comptime is an orthogonal modifier (“evaluate this at compile time”), applicable to a binding and to a block. Together with let/var (section 6), it closes the quadrant:
| runtime | compile time | |
|---|---|---|
| immutable | let / := |
const |
| mutable | var |
comptime var |
const is the short alias of comptime let: a named compile-time constant, the common case. You do not write comptime let for the same reason the bare := is the immutable-runtime; whoever wants to be explicit can write comptime let. comptime var is the mutable scratchpad of compilation: the counter of a loop that generates code, the accumulator of fields of a generated struct:
const SIZE := 64 // = comptime let; compile-time constantcomptime var fields := [] // mutable, exists only at compile timecomptime { loop f in reflect(T).fields { fields.push(...) } // building at compile time}const x := runtime() is an error: the RHS of const has to be comptime-known. Immutable-at-runtime is the :=/let of section 6.
Comptime boundary
Section titled “Comptime boundary”What runs at compile time is almost forced by the architecture, not a free choice: comptime is the pure and deterministic core of the language, minus the runtime effects. No IO, no spawn nor processes, no channel, no syscall; and a comptime does not read a runtime value. It is the language running in the compiler’s interpreter. (Declared exception: the @cimport (section 17) is not user comptime, but rather a privileged phase of the compiler, which reads the header and runs the C compiler embedded and located in the toolchain. The “no IO, no syscall” purity holds for the comptime code that you write; the @cimport is the compiler touching the system during compilation, not the language in the interpreter.)
First-class type families
Section titled “First-class type families”A type constructor is a first-class entity, not just “a function that makes a type”. List is a family that the type system knows as such, which allows abstracting over the unapplied constructor (C[_]) and, with that, writing map, filter, iterators and collectors once for any container:
decl Mappable[C[_]] { fn map[A, B](xs: C[A], f: fn(A) -> B) -> C[B] }decl Filterable[C[_]] { fn filter[A](xs: C[A], p: fn(A) -> bool) -> C[A] }decl Collectable[C[_]] { fn collect[A](it: Iterator[A]) -> C[A] }
// List implements Mappable structurally (implicit satisfaction, section 9)fn (l: List[T]) map[B](f: fn(T) -> B) -> List[B] { ... } // receiver element T is the input; map introduces only B
// lazy pipeline (section 11) gluing with this:source.filter(...).map(...).collect[List]()Why map names the receiver and write does not. An interface writes the receiver’s type only when the result depends on it. write(data: []byte) -> error (section 9) is an action: the type of whoever writes does not appear in the result, it stays implicit. map is a transformation: what comes out (C[B]) is a function of what goes in (C[A]), so the receiver’s type is the signature, and the xs: C[A] is not an exception, it is what a transformation requires. The same rule gives implicit for one and explicit for the other because an action and a transformation are different things.
Container constraints. A generic function over containers uses the same vocabulary as the types ({set} and + interface), lifted to the container by the [...], which carries the element:
C[<element>]: {<containers>} + <interface>- inside the bracket restricts the element:
_(any),_: {u8, string}(set),_ + Display(capability). : {List, Set}restricts which containers C can be (outside the bracket is the container).+ Writeablerequires that the container implement the interface.
All optional, combining as at the type level:
fn ex[C[_]: {List, Set} + Writeable](...) // C is List or Set, and implements Writeablefn ex[C[_: {u8, string, byte}] + Writeable](...) // any container of {u8,string,byte}, Writeablefn ex[C[_: {u8, string, byte}]: {List, Set}](...) // List or Set, of {u8,string,byte}fn ex[C[_]: {List, Set}](...) // List or Set, any elementMultiple containers separate by comma, like any type-var.
C[_] vs C[A]: any container vs container of A. They are distinct declarations, and the choice says what you express. C[_] is “any container”, of an anonymous element: when the transformation changes the element (A goes in, B comes out), you name the elements as separate type-vars and apply C to each:
fn pmap[C[_], A, B](c: C[A], f: fn(A) -> B) -> C[B] // e.g.: List[u8] → List[string]C[A] is “container of A”: it binds the element A in the declaration itself (introduces C and A), for when there is one element, named and bound to the container:
fn keep[C[A]](c: C[A], p: fn(A) -> bool) -> C[A] // filters; element preservedC[A] requires a container: [u8, string](c: u8, ...) is an error, because u8 is not a container type. In summary: C[_] plus separate vars when the element changes (A becomes B), and C[A] when there is a single element, bound to the container.
Where this pays the rent here: pmap (one process per element, collecting back) written once for any container (the machinery of spawn, supervision and collection does not duplicate per List/Set; C only needs to be rebuildable element by element, the capability that Mappable requires), and collect, which materializes a lazy iterator (section 11) into the container that you choose:
fn collect[D[_] + Collectable, A](it: Iterator[A]) -> D[A] // the D comes from the destination's type
as_list: List[int] = primes.collect() // same source...as_set: Set[int] = primes.collect() // ...different output containerThis is “shallow” HKT: powerful without being the footgun of Scala and Haskell. Declaring and using families of any arity is free (Table[R, C, V], Tensor[A, B, C, D], with no kind cost). What has a budget is abstracting over the constructor (C[_]), and it comes with three cuts:
- Arity 1 and 2 only.
C[_](List,Set,Option) andC[_,_](Map[K,V],Result[T,E]) cover the bulk of FP. No higher-order kinds (_[_]): forbidding family-holes inside[...]is a one-line rule that kills the abyss (genericTraverse, monad transformers). The concrete operation remains writable by hand; you just do not abstract over every pair of containers. C[_]is resolved by first-order unification, not by kind-inference. Where a family is used as a parameter, it is written in the[...]; the compiler resolvesCby unifying it against an argument’s container type (the list isList[int], soC[A] = List[A]andA = int). This unification also fires when the argument is itself an already-abstractC[E]forwarded from an enclosing HKT-generic function: insidefn outer[C[_]](c: C[int]) { … inner(c) … }, the callinner(c)resolvesinner’sCfromc: C[int], becauseCis determined inouter’s scope. That is what lets HKT combinators compose — a genericmapcan call a genericfilter. It stays first-order unification (never inference of kinds), so it still kills Scala’s kind-inference hell.- No implicit instance resolution. “How does
mapknow to build aC[B]?” comes from an explicit interface with structural satisfaction (section 9), resolved by monomorphization. The concreteCis known at compile time, which is necessary to build theC[B](an interface value erased by vtable would not know which container to assemble), and does not come from an implicit typeclass search. The HKT enters only as the interface’s signature; the mechanism for finding the implementation is still the already-decided one. It is this that disarms the coherence footgun (Haskell, Scala).
The result: C[_] gives you generic map, filter and collect over containers, but not generic Traverse nor anything that asks the compiler to guess a kind. The footgun3000 becomes a screwdriver: sharp, scoped, no kickback.
Comptime and conditional compilation
Section titled “Comptime and conditional compilation”Since comptime executes logic at compile time, it is also the basis of conditional compilation: generating or omitting code according to compile-time constants. It is the mechanism that will sustain runtime configuration decisions (for example, not embedding a collector if no allocation uses @mm(gc)). The specific design of this is in section 5 (MM shipping).
Comptime reflection: reflect(T)
Section titled “Comptime reflection: reflect(T)”The compiler already knows the layout of each type: which fields exist, of what type, which are pointers. It uses this internally to derive the GC’s trace (section 5). reflect(T) exposes this knowledge to comptime code, so that serialize, hash and trace derive themselves, sweeping the structure at compile time. It is reflection of type, at compile time, not of value at runtime: there is no any (the laziness ammunition of interface{}, which makes you stop thinking about the type), and the runtime comes out at no cost, with field access already resolved in straight code.
reflect(T) is a comptime fn that receives a type and returns a descriptor of its structure. Like all comptime (section 10), it only runs in a compile-time context and does not read a runtime value: it delivers the form, and the value enters the code it helps generate.
reflect is not a builtin: like every function, it lives in a package, the compiler, the language’s compile-time interface (use compiler.{reflect, fail} brings it, section 1). Together with it lives fail, the compilation-failure with a message, used in the unsupported arms further on (fail("non-serializable kind")); it is what takes the place of what would be a loose intrinsic. The accessors that the descriptors expose (f.get, e.tag, variant.get/construct) come with them. The scalar to_bytes/from_bytes (value to bytes and back, in the Int/Float/Bool arms) are methods of mem, where the layout primitives (size_of/align_of/offset_of) also live.
The form of the type is a variant-set. The first question about a type is what its form is: struct? enum? slice? integer? That is reflect(T).kind, and it is a variant-set (section 7): a closed sum with one kind per type-constructor of the language, each carrying what that form has. You do an exhaustive match:
comptime match reflect(T).kind { Struct(s) => ... // s.fields: rich descriptors of the fields Enum(e) => ... // e.tag(v): runtime discriminant; e.variants: each with .name, .tag, .payload (Optional[type]), .get(v), .construct(p) Slice(elem) => ... // elem: type of the element Array(elem, n) => ... // elem + fixed size n Optional(inner) => ... // inner: internal type Tuple(parts) => ... // parts: positional descriptors (like s.fields, but by index) Int(i) => ... // i: { bits, signed } Float(f) => ... // f: { bits } Bool => ... String => ... // valid UTF-8 text (section 16): own kind, NOT Slice(byte), decode revalidates UTF-8 Ptr(p) | ManyPtr(p) | Channel(_) | Function(_) | RawPtr => fail("does not become bytes: address/identity mean nothing on the other side") _ => fail("unsupported kind") // safety net}The form of a type is a closed sum, exactly what the variant-set was made to describe, so reflection describes itself with the language’s own machinery, without a new mechanism. The match is exhaustive (section 7): covering a new form becomes mandatory, and the rule that encoding nailed (“auto-derivation fails for raw *T”) is not a special case, but rather a fail arm. The comptime match is necessary, not just allowed: the arms do things of different types (the Struct arm uses s.fields, which makes no sense if T is Int), so only the arm of T’s real form compiles. And like every match (section 7), the comptime match requires coverage: cover all the kinds or use _, otherwise the derivation compiles for some T and fails mysteriously for others (the non-exhaustiveness footgun). It is the orthogonal modifier comptime (section 10), before over fn/let/var/{} and now over control constructs.
Rich field descriptor. Each field in s.fields is a descriptor with f.name (string), f.type (a type value), f.offset and f.is_pointer, and, being an object, has methods. It also includes the core and extension decorators present on the field. Note the limit: libraries do not define decorators (section 20), so there is no lib-tag for reflection to read. Serialization customization is by naming policy or method override; tag-driven (serde style) is a compiler extension, and the friction is the point.
Reading is f.get(v). The descriptor knows how to extract itself from a value: f.get(v) returns the field f of v, with type f.type. It is the bridge between the descriptor (comptime) and the value (runtime): a method on the descriptor, without a new builtin (Zig’s @field) nor new syntax. Since each f.type differs, the loop over s.fields is unrolled at compile time (below), and each iteration already knows the concrete type.
A product reads with a total accessor; a sum, with a partial one. The .get extends to the other forms, and the difference between them is the product-vs-sum difference. In a tuple (product, like a struct) each element of parts is a positional descriptor with p.get(v) -> p.type, total, since the element is always there. In an enum (sum) each variant of e.variants has variant.get(v) -> Optional[variant.payload], partial: present only in the active variant, absent in the others. The Optional there is not a patch, but rather the sum appearing in the type (a product has all its members; a sum has one). Reading stays uniform: comptime loop plus .get in the three forms, total in the product, partial in the sum. (Each variant also exposes .tag, the discriminant, and .construct(payload), the dual of .get for writing.)
Constructing is reflect(T).construct(fn(f) => …), the dual of f.get. You pass the recipe “given a field’s descriptor, produce its value”, and construct assembles the T. The recipe receives the rich descriptor, so it is format-agnostic (the source is your decision), and construct guarantees evaluation in the declaration order of the fields (in a stream format, order matters). The recipe can be fallible: if it returns Result[f.type, E] (the case of decode, where reading a field can fail), then construct has type Result[T, E] and short-circuits on the first Err. This preserves “no partial init” (either a whole T comes out, or an Err, never a half T) and is what makes decoding a product possible without a ? operator (cut) and without a catch that would only escape the lambda. For the homogeneous array there is the sibling form construct_array(n, fn(i) => …) (count plus producer by index, also fallible):
reflect(T).construct(fn(f) => decode[f.type](src)) // positional/stream// unrolls → T{ c0: decode[T0](src), c1: decode[T1](src), … }reflect(T).construct(fn(f) => decode[f.type](obj[f.name])) // by name (JSON)Construction is by form, routed by the kind’s match: a product is the atomic literal that construct seals (T{…} in a single expression). Struct and tuple are heterogeneous products (positional descriptors of distinct types, s.fields/parts), assembled by construct(fn(m) => …). The array [n]T is a homogeneous product: reflect(Array) exposes (elem, n), not positional descriptors (that is the tuple’s), because they are n copies of the same elem; assemble with the sibling form construct_array(n, fn(i) => …), with n coming from the type (not from the stream). Slice []T is the only dynamic one: a builder by push, and since the count is not in the type, it is written at the front (a serialized usize) and re-read before the push. A sum (enum) constructs per-variant via variant.construct(payload) (the dual of variant.get), routed by the read tag (variant.tag). A product (struct, tuple, array) has to be atomic, not a field-by-field builder, because the language has no partially-initialized value (every binding is born with an RHS; there is no Zig’s undefined). A struct builder would fabricate a T with garbage fields; the literal either compiles whole or does not compile, and a half T never exists. (Slice escapes this because its builder is a List growing normally, not a half-ready struct.)
The unrolling, and the read/write symmetry. How does sweeping reflect(T).fields at compile time become runtime code? The loop/match over something comptime-known is unrolled and resolved at compile time; if the body has a runtime operation, each copy is runtime code. It is not optimization, it is necessity: f.get(v) changes type on each field, and a runtime loop cannot have a body whose type varies per iteration. In a comptime context (inside comptime {}/comptime fn) the unrolling is implicit; in runtime code it takes the marker comptime loop/comptime match. Hence the symmetry: writing accumulates with comptime loop (normal accumulation, unrolled), and reading assembles with construct (atomic, because you cannot push into a struct).
fn serialize[T](v: T, out: mut Buffer) { // writes the form of v into 'out' (out-param = the form of Serializable, without allocating on each call) comptime match reflect(T).kind { Struct(s) => comptime loop f in s.fields { serialize(f.get(v), out) } Tuple(parts) => comptime loop p in parts { serialize(p.get(v), out) } // positional, like a struct Enum(e) => { put_varint(out, e.tag(v)) // discriminant (varint; already serializes a variant without payload) comptime loop variant in e.variants { comptime if variant.payload.is_present() { // only variants WITH a payload p := variant.get(v) // Optional[payload]: present only in the active one if p.is_present() { serialize(p.or_panic(), out) } } } } Slice(_) => { put_varint(out, v.len); loop x in v { serialize(x, out) } } // count (varint) at the front; `.len` is a field of the slice Array(_, _) => loop x in v { serialize(x, out) } // n comes from the type; no count Optional(_) => { out.write_byte(v.is_present() as byte); if v.is_present() { serialize(v.or_panic(), out) } } String => { put_varint(out, v.bytes.len); out.write(v.bytes) } // count + UTF-8 bytes (own kind, section 16; `.bytes` is the view, no bare `len()`) Int(_) | Float(_) | Bool => out.write(v.to_bytes()) Ptr(_) | ManyPtr(_) | Channel(_) | Function(_) | RawPtr => fail("does not serialize: address/identity mean nothing on the other side") _ => fail("non-serializable kind") }}
fn decode[T](src: Readable) -> Result[T, error{Malformed, Truncated}] { // dual of serialize; reads from a Readable (the public one wraps []byte in a Buffer.over(data)) comptime match reflect(T).kind { Struct(_) => reflect(T).construct(fn(f) => decode[f.type](src)) // FALLIBLE construct: the recipe returns Result, construct propagates the 1st Err Tuple(_) => reflect(T).construct(fn(p) => decode[p.type](src)) // same, positional Enum(e) => { tag := read_varint(src) catch |er| { return Err(er) } // discriminant (varint; dual of put_varint) comptime loop variant in e.variants { if variant.tag == tag { // found the variant by the discriminant comptime if variant.payload.is_present() { p := decode[variant.payload.or_panic()](src) catch |er| { return Err(er) } return Ok(variant.construct(p)) } else { return Ok(variant.construct()) } // variant without payload } } return Err(Malformed) // tag outside the set } Slice(elem) => { n := read_varint(src) catch |er| { return Err(er) }; var xs := List[elem].new(); loop _ in 0..n { xs.push(decode[elem](src) catch |er| { return Err(er) }) } return Ok(xs) } // count (varint), then N Array(elem, n) => reflect(T).construct_array(n, fn(i) => decode[elem](src)) // HOMOGENEOUS: elem + count from the type, fallible construct (NOT p.type) Optional(inner) => { b := io.read_byte(src) catch |er| { return Err(er) }; if b == 0 { return Ok(none) }; x := decode[inner](src) catch |er| { return Err(er) }; return Ok(x) } // x: inner → coerces to Optional[inner] String => { n := read_varint(src) catch |er| { return Err(er) }; raw := io.read_bytes(src, n) catch |er| { return Err(er) }; s := string.from[u8](raw) catch |_| { return Err(Malformed) }; return Ok(s) } // count + bytes, revalidates UTF-8 (InvalidUtf8 → Malformed) Int(_) | Float(_) | Bool => T.from_bytes(src) // mem.from_bytes(Readable) → Result[T, Truncated] _ => fail("non-deserializable kind") }}In practice, whoever uses it never sees reflect. The author of json wrote the encode/decode above once; you just declare the type and call:
decl User { id: u64 name: string admin: bool}
u := User{ id: 42; name: "ana"; admin: true }txt := json.encode(u) // {"id":42,"name":"ana","admin":true}back := json.decode[User](txt) // Result[User, json.Error] → Ok(User{42; "ana"; true})No line of serialization written, no derive macro, no tag. The constraint [T + Serializable] in the signature of json.encode triggers the derivation by reflection over User, at compile time, and the encode that runs is straight code (writes the key and the value of each field, in sequence), with no loop nor reflection at runtime.
The same machinery serves hash, and that is why a struct is a key of Map/Set without declaring anything:
decl Point { x: int; y: int }
m := Map[Point, string]()m.set(Point{1; 2}, "origin") // hash of Point derived by reflection, without a Hasher interfacep := m.get(Point{1; 2}) // Optional[string] → "origin"When the wire-name differs from the field, the customization is a naming policy passed to the call (from snake to camel, for example), not a per-field annotation. It is consistent with “a decorator is a subsystem label, not a lib tag” (section 20).
Decode and type invariants. construct sets the fields directly, skipping the constructor that maintains an invariant. A type with an invariant (a SortedList always sorted, an Email always valid, a NonZero never zero) decoded by automatic derivation would come out with the raw bytes from the source, possibly invalid, and the rest of the program that trusts the invariant would break. The line is in two layers: (1) if the type defines a hook validate(self) -> error{…}, the auto-derived decode calls it after the construct and short-circuits on the Err, a light shortcut for an invariant that is only a check (validate rejects, does not transform; serves NonZero, Email, “I require already-canonical bytes, otherwise Err”); (2) for an invariant that needs to transform on decoding (sort, normalize), the type overrides decode (the override that already exists as an escape-hatch) and routes through its constructor, since validate does not sort and the constructor does. Per type you use one or the other; raw auto-derivation is left for types without an invariant. (reflect sees private fields, being the code’s self-reflection over itself, so of course it reaches everything; it is precisely why decode reaches the private ones, and why validate and override exist to re-impose what the constructor would guarantee.)
Core-blessed types reflect by their representation. Duration (the u64-ns, section 14) reflects as the Int it is: it serializes and hashes by the Int arm, with no own kind. BigInt (core) reflects by its internal structure (sign plus digits, a Slice), by the Struct and Slice arms. The ruler: a type gets an own kind (like String) only when serialization does not follow from the form. string needs it because decode has to revalidate UTF-8 and because it is not structurally a slice in the type system; Duration and BigInt do not have that crossed invariant, so the form already suffices.
Reflection is the engine layer of section 10 (“raw comptime … reflection”), the power tool that most never touch directly: you write [T + Serializable] and the derivation runs underneath. Whoever descends to reflect(T) is a library author (encoding, json, hash, Map/Set) or of something that generates types.