· 6 min read ·

Zig's Comptime Generics Are a Reflection System in Disguise

Source: lobsters

The word “reflection” in most languages means something runtime: Java’s getClass().getDeclaredFields(), Python’s getattr, Go’s reflect.TypeOf. It carries connotations of dynamism, overhead, and the kind of code that breaks in subtle ways.

Zig’s comptime does the same thing, but at compile time. That is the crux of Noel Welsh’s observation that comptime is “bonkers” from a type theory perspective. The mechanism that Zig calls generic programming is structurally identical to reflection, just evaluated earlier. Understanding why this matters requires a detour through parametricity, the property that separates genuine generics from reflection-in-disguise.

What parametricity actually says

In 1983, John Reynolds formalized a property he called parametric polymorphism: a polymorphic function must behave uniformly across all type instantiations. The function receives no information about what type it is operating on, so it must treat all values of that type identically.

Philip Wadler translated this into something more immediately useful in his 1989 paper “Theorems for Free!”. The central insight: because a parametric function is uniform, you can derive non-trivial theorems about it purely from its type signature, without reading the implementation.

The canonical example is reverse :: [a] -> [a] in Haskell. From the type alone, you can prove:

map f (reverse xs) = reverse (map f xs)

The derivation is mechanical: reverse cannot inspect elements of type a, so it cannot know whether the list has been mapped. Any rearrangement it performs must commute with mapping. You get this theorem without reading the implementation, without running tests, without trusting the author. The type guarantees it.

This is not just elegant mathematics. GHC uses parametricity to justify compiler optimizations: map f . map g can be rewritten to map (f . g) because the free theorem for map guarantees they are equal. The fusion happens at compile time, and its correctness is proved by the type signature, not verified by testing.

What comptime actually does

Zig’s comptime is a different mechanism with a similar surface appearance. Here is a generic function in Zig:

fn zeroed(comptime T: type) T {
    return switch (@typeInfo(T)) {
        .int, .float => 0,
        .bool => false,
        .pointer => null,
        .@"struct" => |info| blk: {
            var result: T = undefined;
            inline for (info.fields) |field| {
                @field(result, field.name) = zeroed(field.type);
            }
            break :blk result;
        },
        else => @compileError("unsupported type"),
    };
}

@typeInfo(T) returns a tagged union describing the full structure of T: whether it is an integer, a float, a struct with specific named fields and their types, a pointer to some other type, and so on. The function switches on this information and executes completely different code for different types. It recurses into struct fields, accessing them by compile-time-known name using @field.

This is not parametric polymorphism. This is reflection. The function inspects the structure of its type argument and acts differently based on what it finds. The only difference from Java’s getDeclaredFields() is that the inspection happens at compile time and the function is monomorphized: a separate implementation is generated for each concrete T.

The free theorem that does not exist

Consider a Zig function with this signature:

fn transform(comptime T: type, items: []T) []T

In Haskell, an equivalent signature forall a. [a] -> [a] would guarantee map f (transform xs) = transform (map f xs). You would know transform can only rearrange elements, not inspect or replace them.

In Zig, transform can do anything. It can check if (@typeInfo(T) == .int) and sort numerically. It can iterate over struct fields if T is a struct. It can call @compileError for types it does not recognize. The signature tells you nothing about any of this.

This is not a theoretical problem in isolation. When you use a Zig library that exports functions taking comptime T: type, you cannot reason about what those functions do from their types alone. You have to read the implementations. This is the same epistemic situation as using a Java library with reflection: the type signature is documentation, not specification.

The C++ comparison and why Zig is clearer

C++ templates break parametricity too. Template specialization allows entirely different implementations for different type arguments, and if constexpr permits branching within a single function body:

template<typename T>
void process(T value) {
    if constexpr (std::is_integral_v<T>) {
        // integer path
    } else if constexpr (std::is_same_v<T, std::string>) {
        // string path
    }
}

The function signature template<typename T> void process(T value) is just as uninformative as Zig’s fn process(comptime T: type, value: T) void. Both break parametricity. Both are reflection-like dispatch disguised as generic code.

The difference is that Zig is more honest about the mechanism. @typeInfo(T) is an explicit, documented built-in that returns a structured tagged union you switch on. The C++ equivalent requires knowing <type_traits>, understanding SFINAE, and checking whether partial specializations are in scope. Zig’s compile-time reflection is at least legible.

What compile-time reflection enables

The reason Zig chose this design is that compile-time reflection is more expressive than parametric generics for a specific class of problems: programs that need to adapt behavior to the entire structure of a type, not just to a declared interface.

Zig’s std.json.stringify can serialize any struct, recursing into nested structs, handling optional fields, encoding enum variants by name, all without any annotation or derive macro. The full structural information about the type is available through @typeInfo, and inline for over info.fields iterates at compile time. The result is a serializer as capable as Rust’s serde, implemented as an ordinary function rather than a proc macro expansion step.

Parametric generics cannot do this directly. In Haskell, you need GHC.Generics with its Rep type family and Generic typeclass, which compile to essentially the same structural introspection but through a more constrained, theoretically motivated interface. In Rust, serde requires #[derive(Serialize)] to generate the structural traversal code. Zig’s approach is direct: the structural information is just available, always, through @typeInfo.

The inline for keyword is key here. It allows iterating over a comptime-known sequence where each iteration can generate different code because the types of fields vary. This is what a macro would do in other languages, written as a loop:

inline for (info.fields) |field| {
    // field.type is different in each iteration
    // this generates separate code per field
    try serialize(@field(value, field.name), writer);
}

No trait bound, no derive macro, no code generation tool. Just a loop that the compiler unrolls and specializes.

Where the trade-off lands

Zig’s design reflects a clear priority: for systems programming, the ability to write type-adaptive code without a macro system or derive infrastructure is worth more than the reasoning guarantees that parametricity provides.

This is a coherent position. Parametricity matters most when you are building large-scale abstractions composed by many programmers who need to reason about them without reading implementations. In Zig’s target domain, writing OS kernels, embedded firmware, and performance-critical infrastructure, the primary audience for a function is usually the person writing it, and the value of compile-time reflection for generating efficient, allocation-free serializers and formatters is concrete.

The cost is real, though. When you cannot derive free theorems from type signatures, you lose the ability to reason about functions compositionally. The refactoring guarantee that changing the internal representation of a type cannot affect functions that are polymorphic over it disappears. Every function touching comptime T: type potentially cares about the full structure of T, and there is nothing in the signature to tell you whether it does.

Haskell and Rust’s trait systems provide a middle ground. A Rust function fn process<T: Display>(x: T) can call x.to_string() but cannot branch based on whether T is i32 or String. The bound declares what operations are available; the function must work uniformly within those bounds. You get a weakened form of parametricity: free theorems modulo the declared interface. Zig deliberately has no equivalent mechanism.

The original article frames this as “bonkers,” and the framing is apt: comptime is surprising precisely because it looks like generics but does not behave like them. Calling it compile-time reflection is more accurate, and understanding it as such helps predict where it will behave unexpectedly. You do not expect Java’s getDeclaredFields() to obey free theorems. You should not expect Zig’s @typeInfo to either.

Was this interesting?