Zig's comptime: Generics Without a Second Language
Zig's comptime turns generics, lookup tables, and compile-time validation into ordinary Zig code. A worked fixed-capacity stack example, plus the real trade-offs: compile times, error messages, and version churn.
16 Feb 2026, 13:14 UTC

If you've written C++ templates or Rust macros, you know the feeling: the moment you need a generic data structure, you leave the comfortable world of normal code and enter a parallel language with its own syntax, its own error messages, and its own debugging tools. Zig takes a different bet. Its answer to generics, lookup tables, and compile-time validation is a single keyword — comptime — that lets ordinary Zig code run while the compiler is working.
The thesis of this post is simple: comptime is worth learning not because it's exotic, but because it removes an entire category of tooling. One language handles runtime code, metaprogramming, and even the build script.
What comptime actually means
In Zig, any expression can be evaluated at compile time if the compiler has enough information. You make this explicit in two common ways: prefixing something with comptime, or declaring a parameter as comptime T: type. The second form is the interesting one — because types are first-class values at compile time, a generic function is just a function that takes a type as an argument.
There is no template instantiation machinery hidden from you. If a function receives a type parameter, the compiler evaluates that function once per distinct type, and you can read the code top to bottom to see exactly when that happens. Compared to C++, where instantiation is implicit and error messages surface deep inside library internals, this explicitness is the whole point.
A worked example: a fixed-capacity stack
Here's the canonical pattern: a function that returns a struct type, parameterized on the element type and capacity. This compiles with recent Zig releases (the example targets the 0.13/0.14 era — verify against your installed version, since pre-1.0 Zig changes std library details between releases):
const std = @import("std");
fn Stack(comptime T: type, comptime capacity: usize) type {
return struct {
items: [capacity]T = undefined,
len: usize = 0,
const Self = @This();
pub fn push(self: *Self, item: T) !void {
if (self.len == capacity) return error.Full;
self.items[self.len] = item;
self.len += 1;
}
pub fn pop(self: *Self) ?T {
if (self.len == 0) return null;
self.len -= 1;
return self.items[self.len];
}
};
}
pub fn main() !void {
var s = Stack(u32, 8){};
try s.push(42);
const v = s.pop();
std.debug.print("popped: {?}\n", .{v});
}
Run it from a terminal with the compiler on your PATH: zig build-exe stack.zig && ./stack. No special permissions are needed; the only placeholder is the filename. The expected result is a clean build and a line of output showing the popped value. If you instead get a compile error mentioning std.debug.print formatting, check your Zig version first — that's the most likely drift point.
The key observation: Stack(u32, 8) and Stack(u8, 64) are two distinct, fully concrete types. There is no runtime dispatch, no pointer-erased generic container, no allocator unless you add one. The capacity is baked into the struct layout.
Beyond generics: tables and validation
Generics are the flashy use, but two quieter ones pay off daily in real projects:
- Build-time lookup tables. A function that computes, say, a CRC table or a sine approximation table can be called inside a
comptimeblock, and the result lands in the binary as constant data. The computation never runs on the user's machine. - Compile-time validation. Because comptime code is ordinary Zig, you can validate a format string, a configuration struct, or a set of enum values at build time and fail the compile with
@compileErrorif something is wrong — catching misconfiguration before the program ever ships.
The build system reinforces the same idea: build.zig is itself Zig code executed to configure compilation. Metaprogramming, generics, and build scripting share one language and one mental model, which is a genuine reduction in the number of things a new team member must learn.
The honest trade-offs
Comptime is not free, and treating it as such leads to real pain:
- Compile times grow. Every comptime evaluation is work the compiler does on every build. A large generated table or a heavily recursive comptime algorithm can turn a fast edit-compile loop into a coffee break. Keep comptime computation proportional to its payoff.
- Error messages can be rough. A mistake inside deeply nested comptime evaluation produces a trace through every evaluation frame. The mitigation is the same as for any code: keep comptime logic small, factor it into testable functions, and prefer
@compileErrorwith a clear message over letting a confusing type error bubble up. - No runtime state. Comptime code cannot do arbitrary I/O or read anything that only exists at runtime. If you find yourself fighting this boundary, the computation probably belongs at runtime.
- Version churn. Zig is pre-1.0. Syntax and std APIs shift between releases, so pin your compiler version in CI and re-verify examples when upgrading.
How to check you got the zero-cost part
Don't take "zero-cost" on faith — verify it on your own target. Compile with optimizations (zig build-obj -O ReleaseFast stack.zig) and inspect the result with objdump -d. For the stack example, you should see direct array indexing with the capacity as an immediate constant, not any dispatch or indirection. For a comptime-generated table, look for the data in the binary's read-only section rather than initialization code at startup. If the abstraction shows up in the disassembly as extra work, that's a signal to simplify — not a law of nature.
The actionable takeaway: next time you reach for a code generator, a macro layer, or a runtime-validated config file in a Zig project, ask whether a small comptime function would do the job instead. Start with something tiny — a validated constant or a fixed-capacity container — and let the pattern earn its place in your codebase.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.