Zig Comptime: Zero-Cost Generics Without void* Tricks
Zig's comptime turns types into ordinary compile-time arguments, giving you monomorphized generic containers with no void* and no runtime cost. A worked List(T) example, plus the trade-offs.
01 Apr 2026, 17:46 UTC

If you've written reusable containers in Zig the naive way, you've hit the fork in the road: duplicate the same list code for every element type, or fall back to *anyopaque pointers and lose type safety at the boundary. Zig's answer is comptime — types are ordinary compile-time values, so a generic container is just a function that takes a type and returns a type. The generated code is monomorphized: each instantiation compiles to a concrete, fully typed implementation with no runtime dispatch and no indirection.
Types as function arguments
In Zig, type is itself a type, and any parameter marked comptime must be known at compile time. That combination is the whole generic system. There are no template keywords, no trait bounds, no separate generics syntax:
fn List(comptime T: type) type {
return struct {
items: []T,
len: usize,
allocator: std.mem.Allocator,
const Self = @This();
pub fn init(allocator: std.mem.Allocator) Self {
return .{ .items = &.{}, .len = 0, .allocator = allocator };
}
pub fn push(self: *Self, value: T) !void {
if (self.len == self.items.len) {
const cap = if (self.items.len == 0) 8 else self.items.len * 2;
const buf = try self.allocator.alloc(T, cap);
@memcpy(buf[0..self.len], self.items[0..self.len]);
self.allocator.free(self.items);
self.items = buf;
}
self.items[self.len] = value;
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 deinit(self: *Self) void {
self.allocator.free(self.items);
}
};
}Calling List(u32) evaluates the function at compile time and yields a struct whose fields and methods are all concretely typed. List(u32) and List([]const u8) are distinct types, each with its own machine code — exactly what a C++ compiler does with templates, but expressed in plain Zig.
A runnable check
Save this as example.zig (assumes Zig 0.13.x; run zig version to confirm — the standard library API shifts between 0.x releases):
const std = @import("std");
// paste the List function here
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
var list = List(u32).init(gpa.allocator());
defer list.deinit();
try list.push(1);
try list.push(2);
try list.push(3);
while (list.pop()) |v| {
std.debug.print("{d} ", .{v});
}
std.debug.print("\n", .{});
}Build and run from the directory containing the file, with normal user permissions:
zig build-exe example.zig
./example # example.exe on WindowsExpected output is 3 2 1 (LIFO order from pop). If compilation fails with an error mentioning GeneralPurposeAllocator, you're likely on Zig 0.14+ where allocator APIs changed — check zig version and adjust accordingly. Note the try on push: allocation failure is an explicit error, not a hidden abort.
Where comptime bends
The cost of this model is paid at compile time and in diagnostics. Every List(T) instantiation is separately analyzed and codegen'd, so heavy generic use grows build times and binary size in the same way C++ templates do. Errors surface inside the generic body with the instantiation context attached, which can make messages long; @compileLog(value) is the standard tool for inspecting comptime values when something doesn't resolve.
The harder constraint: comptime parameters must be fully resolvable at compile time. You cannot pick T based on a runtime value — there is no runtime polymorphism here. If you need heterogeneous elements, you reach for tagged unions or vtable-style interfaces (like std.mem.Allocator itself) instead. That split — static dispatch via comptime, dynamic dispatch via explicit fat pointers — is a deliberate Zig design decision, and codebases that respect it stay easy to reason about.
Verifying the "zero-cost" claim
Don't take the abstraction-cost claim on faith. Compile with zig build-exe -O ReleaseFast -femit-asm=example.s example.zig and inspect the assembly: the push call for List(u32) operates directly on a []u32 slice with no type tags or indirection. You can also compare against a hand-written u32-only list — the generated code should be effectively identical. For a quicker sanity check, zig build-exe -O ReleaseSmall and compare binary sizes between one instantiation and three; the delta tells you exactly what monomorphization costs you.
The practical takeaway: reach for comptime T: type whenever you'd otherwise duplicate a data structure or erase types. Keep generic bodies small, push policy decisions (allocators, growth factors) into parameters, and verify with -femit-asm when the abstraction's cost actually matters.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.