Metaprogramming with Zig's comptime

What comptime actually is

Zig has no preprocessor, no template language, and no separate constexpr dialect. Instead, any Zig code can be forced to run at compile time using the comptime keyword. The compiler is, in effect, an interpreter for Zig itself. This means generics, reflection, and code generation are written in ordinary Zig syntax, using the same control flow, functions, and types as runtime code.

A value, a function call, or a block can be marked comptime. When something is comptime, the compiler guarantees it is fully evaluated before the binary exists — the result is a constant baked into the compiled output.

const x = // implicitly comptime: top-level const
    1 + 2;

comptime {
    // this block runs during compilation, not at runtime
    var sum = 0;
    for (0..10) |i| sum += i;
    if (sum != 45) @compileError("math is broken");
}

Generic functions via comptime parameters

Zig has no generic syntax like <T>. Instead, a function parameter can be declared comptime, and if that parameter is a type, the function becomes generic.

fn max(comptime T: type, a: T, b: T) T {
    return if (a > b) a else b;
}

const m = max(i32, 3, 7);
const f = max(f64, 1.5, 0.2);

Every distinct T the function is called with produces a separate compiled instantiation — the same effect as C++ template instantiation, but without a separate template syntax. T is just a runtime-shaped value that happens to be known at compile time; ordinary Zig code manipulates it (compare it, branch on it, pass it along).

anytype

anytype is shorthand for an inferred comptime type parameter. It gives duck-typed generics without spelling out the type variable:

fn add(a: anytype, b: @TypeOf(a)) @TypeOf(a) {
    return a + b;
}

@TypeOf is itself a comptime-only builtin — it returns the type of an expression without evaluating it, and that returned type can be fed straight back into the signature.

Types as first-class values

Because type is an ordinary value at compile time, functions can return types. This is how Zig builds generic containers — there is no separate "generic struct" declaration, just a function that returns a struct literal.

fn Stack(comptime T: type) type {
    return struct {
        items: std.ArrayList(T),

        const Self = @This();

        pub fn init(allocator: std.mem.Allocator) Self {
            return .{ .items = std.ArrayList(T).init(allocator) };
        }

        pub fn push(self: *Self, value: T) !void {
            try self.items.append(value);
        }

        pub fn pop(self: *Self) ?T {
            return self.items.popOrNull();
        }
    };
}

var s = Stack(u32).init(allocator);
try s.push(10);

Stack(u32) and Stack(f64) are different, fully concrete types — the compiler evaluates the function body of Stack at compile time, once per unique T, and caches the result. There's no runtime dispatch or vtable involved unless you write one explicitly.

comptime variables and loop unrolling

A comptime var holds mutable state that only exists during compilation. Combined with inline for / inline while, this is how Zig unrolls loops or generates repeated code with per-iteration compile-time values.

const fields = .{ "x", "y", "z" };

inline for (fields) |name| {
    // `name` is comptime-known on each iteration
    @compileLog(name);
}

Regular for/while loops over runtime data compile to a single loop body. inline for instead unrolls: the loop body is emitted once per element, and each element is available as a comptime constant inside that copy — necessary when the loop body needs to do type-level things (like access a differently-named or differently-typed field per iteration).

Reflection with @typeInfo

@typeInfo turns any type into a tagged union (std.builtin.Type) describing its shape: fields, size, alignment, enum tags, function signature, and so on. This is Zig's answer to reflection, and it's usable in ordinary comptime code, not a separate reflection API.

fn printFields(comptime T: type) void {
    const info = @typeInfo(T);
    switch (info) {
        .Struct => |s| {
            inline for (s.fields) |field| {
                std.debug.print("{s}: {s}\n", .{ field.name, @typeName(field.type) });
            }
        },
        else => @compileError("printFields expects a struct"),
    }
}

Related builtins commonly used alongside it:

BuiltinPurpose
@TypeOf(expr)Type of an expression, without evaluating side effects
@typeInfo(T)Structured description of a type's shape
@typeName(T)String name of a type
@hasField(T, name)Whether a struct/union has a given field
@hasDecl(T, name)Whether a type has a given declaration
@field(value, name)Access a field by a comptime-known name string
@compileError(msg)Fail compilation with a message
@compileLog(args)Print values during compilation, for debugging comptime code

Generating code from field names

fn zeroInit(comptime T: type) T {
    var result: T = undefined;
    inline for (@typeInfo(T).Struct.fields) |field| {
        @field(result, field.name) = std.mem.zeroes(field.type);
    }
    return result;
}

@field is what makes this different from a runtime loop over field names: field.name is a compile-time string, so @field(result, field.name) resolves to a concrete, statically-typed field access after unrolling — there is no string lookup left at runtime.

Rules governing comptime

comptime {
    @setEvalBranchQuota(10_000);
    // heavier compile-time computation here
}

Where this replaces other languages' metaprogramming

Mechanism elsewhereZig equivalent
C++ templatesFunctions with comptime type parameters
C preprocessor macrosRegular functions run at comptime, or inline functions
Rust derive macros / proc macros@typeInfo-driven code generation inside a normal function
constexpr / constevalImplicit for constants; explicit comptime blocks for the rest
Reflection librariesBuilt-in @typeInfo, no external dependency

The practical upshot: there's one language to learn, one debugger mental model (comptime code can be logged with @compileLog and reasoned about like any other Zig function), and no separate macro-expansion pass that produces code invisible to the type checker until after expansion. Generic code is checked at the point of instantiation, with normal compiler errors pointing at the actual failing line.

Examples target current stable Zig syntax (std.ArrayList, @typeInfo(T).Struct). Field enumeration and container APIs have shifted across Zig versions before 1.0; check the changelog for the exact release in use.