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:
| Builtin | Purpose |
|---|---|
@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
- Purity of inputs. A value can only be used in a
comptimecontext if it is itself comptime-known. A function parameter read from user input can never flow into acomptimeblock or acomptime-parameter position. - No I/O, no runtime allocator, no undefined behavior tolerance. Comptime code executes inside the compiler's interpreter. It cannot touch the filesystem or network, and safety checks (like out-of-bounds access) are enforced unconditionally.
- Deduplication by value. Two calls to a generic function/type constructor with equal comptime arguments resolve to the same instantiation —
Stack(u32)called from two different files is one type, comparable and interchangeable. - Branch quota. Compile-time execution has a step limit, controlled by
@setEvalBranchQuota(n), to catch runaway comptime loops (e.g. compile-time computing large Fibonacci numbers or parsing long strings) before they hang the compiler.
comptime {
@setEvalBranchQuota(10_000);
// heavier compile-time computation here
}
Where this replaces other languages' metaprogramming
| Mechanism elsewhere | Zig equivalent |
|---|---|
| C++ templates | Functions with comptime type parameters |
| C preprocessor macros | Regular functions run at comptime, or inline functions |
| Rust derive macros / proc macros | @typeInfo-driven code generation inside a normal function |
constexpr / consteval | Implicit for constants; explicit comptime blocks for the rest |
| Reflection libraries | Built-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.
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.