Running code while compiling
There are normally two separate times in a program’s life: compile time, when the compiler turns your source into a program, and runtime, when the program runs. Zig lets you run ordinary Zig code at compile time too, using the keyword comptime. The results are baked into the program, so they cost nothing when it runs.
This one feature does the job that other languages need several tools for: C’s preprocessor macros, C++ templates, TypeScript and Java generics, and C++’s constexpr.
Compile-time values
Some values are always known at compile time: number literals, types, and constants declared at the top level of a file. You can also force a calculation to happen at compile time:
const std = @import("std");
fn square(n: u32) u32 {
return n * n;
}
const a = square(12); // top level: computed by the compiler
pub fn main() void {
const b = comptime square(12); // forced to compile time
const c = square(12); // an ordinary runtime call
std.debug.print("{d} {d} {d}\n", .{ a, b, c });
}
A more useful example is building a lookup table during compilation:
const squares = blk: {
var table: [16]u32 = undefined;
for (&table, 0..) |*slot, i| slot.* = @intCast(i * i);
break :blk table;
};
The compiler runs that loop and stores the finished 16 numbers in the executable. At runtime, squares[5] is just a memory read.
The compiler protects itself from infinite loops: compile-time code has a limit on how many branches it can take. For bigger tables you can raise it with @setEvalBranchQuota.
Generics: functions that return types
This is where comptime gets powerful. Since types are values at compile time, a function can take a type as a parameter and return a brand-new type. That’s how Zig does generics, with no separate generics syntax at all:
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, value: T) error{Full}!void {
if (self.len == capacity) return error.Full;
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];
}
};
}
How to read it:
Stackis an ordinary function. Its parameters are markedcomptimebecause they must be known while compiling: a type, and a capacity.- Its return type is
type. The body builds astructusingTandcapacityand returns it. const Self = @This();gives the struct a name it can use for itself, since it has no name of its own.@This()means “the type I’m inside”.Stack(u8, 4)runs the function at compile time and produces a real type: a struct holding a[4]u8and a length.Stack(f32, 100)produces a different one. Using it:
var s: Stack(u8, 4) = .{};
try s.push(1);
try s.push(2);
const top = s.pop(); // a ?u8 holding 2
Calling Stack(u8, 4) a second time gives back exactly the same type, because the compiler caches the result. You’ve already used this pattern without knowing it: std.ArrayList(u8) is a function call that returns a type.
anytype: accept anything, checked at compile time
A parameter declared as anytype accepts an argument of any type. The compiler makes a separate copy of the function for each type you actually call it with, and checks each copy. If you pass something that doesn’t support what the function does with it, you get a compile error at the call.
Inside, you can ask for the type with @TypeOf(x) and examine its structure with @typeInfo:
fn describe(x: anytype) []const u8 {
return switch (@typeInfo(@TypeOf(x))) {
.int => "integer",
.float => "float",
.pointer => "pointer",
else => "something else",
};
}
That switch runs at compile time, so each copy of describe contains only the one branch it needs. Code that examines types like this is called reflection.
inline for: looping over types
A normal for runs at runtime, and every iteration has to work with the same types. inline for unrolls the loop at compile time, so each iteration can handle something different, such as the fields of a struct, which can each have a different type:
const Point = struct { x: i32, y: i32, visible: bool };
const p = Point{ .x = 1, .y = 2, .visible = true };
inline for (std.meta.fields(Point)) |f| {
std.debug.print("{s} = {any}\n", .{ f.name, @field(p, f.name) });
}
// x = 1
// y = 2
// visible = true
@field(p, f.name) reads a field by its name, where the name is a compile-time string. This is how std.json can serialize any struct you give it, and how printing knows what’s inside your types.
When to reach for comptime
As a beginner, the main uses are:
- Generic containers and functions, like
fn Stack(comptime T: type). - Precomputed tables and constants.
- Functions that work for many types (
anytype), still checked by the compiler. Don’t use it to be clever. If a plain function does the job, a plain function is better.
Recap
comptimeruns ordinary Zig during compilation; results cost nothing at runtime.- Top-level constants are always computed at compile time.
- Generics are functions taking
comptime T: typeand returning a struct.@This()names it. anytypemakes a checked copy of a function per argument type;@typeInfoinspects types.inline forunrolls loops at compile time, for example to walk a struct’s fields.