13 KiB
Comptime Reference
Zig's comptime system enables metaprogramming through partial evaluation and type reflection. This reference covers comptime fundamentals, type reflection, and common techniques.
Table of Contents
Fundamentals
Comptime Parameters
Values that must be known at compile time. Types are always comptime.
fn max(comptime T: type, a: T, b: T) T {
return if (a > b) a else b;
}
const result = max(i32, 5, 10); // T=i32 known at compile time
A comptime parameter means:
- At the callsite, the value must be known at compile time
- In the function definition, the value is comptime-known
Comptime Variables
Variables whose loads/stores happen at compile time.
comptime var i: usize = 0;
inline while (i < 3) : (i += 1) {
// i is comptime-known each iteration
}
Comptime Blocks
Force expression evaluation at compile time.
const primes = comptime blk: {
var result: [10]u32 = undefined;
// ... compute primes ...
break :blk result;
};
comptime {
// All code here runs at compile time
if (@sizeOf(MyStruct) > 64) @compileError("too large");
}
Container-Level Comptime
Top-level constants whose values must be compile-time known are evaluated on demand at compile time. This does not make every top-level declaration a comptime block or imply eager evaluation.
// These are computed at compile time automatically
const lookup_table = generateTable();
const config = parseConfig(@embedFile("config.json"));
Type Reflection
Builtins
| Builtin | Purpose |
|---|---|
@typeInfo(T) |
Get type metadata as std.builtin.Type |
@Int / @Struct / @Union / @Enum / @Pointer / @Fn / @Tuple |
Zig 0.16 type-construction builtins replacing removed @Type |
@TypeOf(expr) |
Get type of expression |
@typeName(T) |
Get type name as [:0]const u8 |
@hasDecl(T, name) |
Check if type has declaration |
@hasField(T, name) |
Check if type has field |
@field(value, name) |
Access field by comptime-known name |
Zig 0.16 Type Construction
@Type is removed in Zig 0.16. Continue using @typeInfo for reflection, but construct new types with the specific builtin for the container or scalar type you need:
const Id = @Int(.unsigned, 32);
const Pair = @Tuple(&.{ []const u8, Id });
For generated structs/unions/enums, use @Struct, @Union, and @Enum with separate arrays of field names, field types/values, and field attributes. Prefer plain Zig syntax when the type can be written directly.
Zig 0.16 also changed type resolution:
- Field analysis is lazier, so namespace-like types can often exist without resolving all fields.
- Some dependency-loop diagnostics changed and should be evaluated in context rather than dismissed.
- Pointers to comptime-only types may exist at runtime, but runtime dereference remains invalid.
- Explicitly aligned pointer types are distinct from naturally aligned pointer types, though they often coerce.
- Zero-bit tuple fields are no longer implicitly marked
comptimein type info.
Accessing Type Info for Keywords
Use @"keyword" syntax because union, struct, enum are reserved:
const union_info = @typeInfo(MyUnion).@"union";
const struct_info = @typeInfo(MyStruct).@"struct";
const enum_info = @typeInfo(MyEnum).@"enum";
const fn_info = @typeInfo(@TypeOf(myFn)).@"fn";
Common Type Info Fields
Struct:
const info = @typeInfo(MyStruct).@"struct";
// info.fields: []const StructField
// info.decls: []const Declaration
// info.is_tuple: bool
Union:
const info = @typeInfo(MyUnion).@"union";
// info.tag_type: ?type (null if untagged)
// info.fields: []const UnionField
// info.layout: .auto, .@"extern", .@"packed"
Enum:
const info = @typeInfo(MyEnum).@"enum";
// info.tag_type: type (backing integer)
// info.fields: []const EnumField
// info.is_exhaustive: bool
Creating Union Values with Comptime Tag
Use @unionInit when the tag is comptime-known:
const Action = union(enum) {
move: struct { x: i32, y: i32 },
jump,
attack: u32,
};
// Create union with comptime-known field name
const action = @unionInit(Action, "move", .{ .x = 10, .y = 20 });
Loop Variants
comptime for
Full compile-time evaluation. Can use break to return values. Cannot reference runtime values.
// Return value from comptime loop
fn hasField(comptime T: type, comptime name: []const u8) bool {
const fields = @typeInfo(T).@"struct".fields;
return comptime for (fields) |f| {
if (std.mem.eql(u8, f.name, name)) break true;
} else false;
}
// Computation in comptime block
fn sumComptime(comptime values: []const i32) i32 {
comptime {
var sum: i32 = 0;
for (values) |v| sum += v;
return sum;
}
}
Verified in stdlib: std/Build.zig:1953
inline for
Loop unrolling with code generation. Body is duplicated per iteration and may reference runtime values. Like other loop expressions, an inline for can use break with a value when its control flow is valid.
fn printFields(value: anytype) void {
const T = @TypeOf(value);
const fields = @typeInfo(T).@"struct".fields;
// Each iteration generates separate code
inline for (fields) |field| {
const field_value = @field(value, field.name);
std.debug.print("{s} = {any}\n", .{ field.name, field_value });
}
}
// Runtime comparison via unrolling
fn eqlAny(comptime T: type, a: T, b: T) bool {
const fields = @typeInfo(T).@"struct".fields;
inline for (fields) |field| {
if (@field(a, field.name) != @field(b, field.name)) {
return false; // Runtime return
}
}
return true;
}
Verified in stdlib: std/meta.zig:27, compiler_rt/fmax.zig:63
Decision Table
| Need | Use | Reason |
|---|---|---|
| Evaluate the entire loop and its result at compile time | comptime for |
All values and control flow must be comptime-known |
| Access runtime values in body | inline for |
Comptime can't see runtime |
| Type-level computation only | comptime for |
Clearer intent, no code gen |
| Generate code per iteration | inline for |
Each iteration = separate code |
| Normal runtime iteration | regular for |
No unrolling needed |
Branch Elimination
Comptime-known conditions eliminate dead branches entirely—no runtime cost.
Basic Elimination
fn process(comptime T: type, value: T) T {
if (T == bool) {
return !value; // Only exists for bool
} else {
return value + 1; // Only exists for integers
}
}
Platform-Specific Code
const builtin = @import("builtin");
const native_endian = builtin.cpu.arch.endian();
pub fn readIntBig(comptime T: type, bytes: []const u8) T {
comptime std.debug.assert(@typeInfo(T) == .int);
std.debug.assert(bytes.len >= @sizeOf(T));
const value: T = @bitCast(bytes[0..@sizeOf(T)].*);
if (comptime native_endian == .big) {
return value;
} else {
return @byteSwap(value);
}
}
Propagating Across Functions
Comptime parameters specialize the function and make conditions that depend on them compile-time known. inline fn additionally requests call-site inlining; it is not required merely to eliminate a comptime-known branch:
// The enabled branch is specialized at compile time.
fn maybeLog(comptime enabled: bool, msg: []const u8) void {
if (enabled) std.debug.print("{s}\n", .{msg});
}
// inline additionally requests call-site inlining.
inline fn maybeLogInline(comptime enabled: bool, msg: []const u8) void {
if (comptime enabled) std.debug.print("{s}\n", .{msg});
}
pub fn example() void {
maybeLogInline(false, "debug"); // Entire call eliminated
maybeLogInline(true, "important"); // Only this generates code
}
Verified in stdlib: std/math.zig:708, std/log.zig:122
Type Generation
Returning Types from Functions
fn Pair(comptime A: type, comptime B: type) type {
return struct {
first: A,
second: B,
const Self = @This();
pub fn swap(self: Self) Pair(B, A) {
return .{ .first = self.second, .second = self.first };
}
};
}
const IntStr = Pair(i32, []const u8);
var p: IntStr = .{ .first = 42, .second = "hello" };
Generating Union Subsets
Generate a subset union type from a larger union:
Zig 0.16 note: the following pattern is historical because it uses removed @Type. In new code, keep the reflection idea but build the resulting type with @Union and related 0.16 type-construction builtins.
pub fn Subset(comptime T: type, comptime fields: []const std.meta.FieldEnum(T)) type {
const source_info = @typeInfo(T).@"union";
var new_fields: [fields.len]std.builtin.Type.UnionField = undefined;
for (fields, 0..) |field_enum, i| {
const field_name = @tagName(field_enum);
for (source_info.fields) |source_field| {
if (std.mem.eql(u8, source_field.name, field_name)) {
new_fields[i] = source_field;
break;
}
}
}
return @Type(.{
.@"union" = .{
.layout = source_info.layout,
.tag_type = std.meta.FieldEnum(@Type(.{
.@"union" = .{
.layout = source_info.layout,
.tag_type = null,
.fields = &new_fields,
.decls = &.{},
},
})),
.fields = &new_fields,
.decls = &.{},
},
});
}
Converting Between Union Types
Use inline else to capture tag at comptime:
/// Convert subset to full union type.
pub fn toFull(comptime Full: type, subset: anytype) Full {
return switch (subset) {
inline else => |payload, tag| @unionInit(Full, @tagName(tag), payload),
};
}
/// Try to narrow full union to subset type.
pub fn toSubset(comptime Subset: type, full: anytype) ?Subset {
const subset_fields = @typeInfo(Subset).@"union".fields;
return switch (full) {
inline else => |payload, tag| {
inline for (subset_fields) |sf| {
if (std.mem.eql(u8, sf.name, @tagName(tag))) {
return @unionInit(Subset, sf.name, payload);
}
}
return null;
},
};
}
Verified in stdlib: std/meta.zig, std/json/static.zig:299-302
Limitations
Zig's comptime is deliberately constrained for cross-compilation safety and code clarity.
No Host Architecture Detection
// This reflects TARGET, not host
const is_64bit = comptime @sizeOf(usize) == 8;
// Use build.zig for host detection
// build.zig runs as a program and can query host
No String-to-Code Evaluation
// NOT POSSIBLE
const code = "x + y";
const result = @eval(code); // No such builtin
// Alternative: Parse strings to data structures at comptime
const query = comptime sql.parse("SELECT * FROM users");
No Runtime Type Information
// This works - type known at comptime
fn getTypeName(value: anytype) []const u8 {
return @typeName(@TypeOf(value));
}
// NOT POSSIBLE - can't turn runtime string into type
fn typeFromName(name: []const u8) type { ... }
Comptime I/O Boundary
// Ordinary runtime filesystem I/O is not available at comptime.
// This is intentionally invalid; Zig 0.16 has no such comptime std.Io call:
// const config = comptime std.Io.Dir.cwd().readFileAlloc(...);
// Compiler-supported static input is available:
const embedded = @embedFile("config.json");
// Alternatives:
const config = @embedFile("config.json"); // Static embedding
// Or use build.zig which runs as a normal program
No Dynamic Method Injection
// This works - methods defined in type
fn Pair(comptime A: type, comptime B: type) type {
return struct {
first: A,
second: B,
pub fn swap(self: @This()) Pair(B, A) { ... }
};
}
// NOT POSSIBLE - can't add methods to existing types
fn addMethod(comptime T: type, comptime name: []const u8, impl: anytype) type { ... }
Summary Table
| Want to do | Comptime? | Alternative |
|---|---|---|
| Type reflection | Yes | @typeInfo, @TypeOf |
| Generate types | Yes | Return struct from function |
| Add methods to types | No | Define in type definition |
| Read files | Static inputs only | @embedFile; use build.zig for ordinary I/O |
| Syscalls | No | build.zig runs as program |
| Parse strings to code | No | Parse to data structures |
| Host detection | No | Build system queries |
Design Rationale
These constraints ensure:
- Cross-compilation works - comptime sees target, not host
- Code is readable - no hidden code generation
- Builds are reproducible - no I/O side effects
- All API is visible - no dynamic method injection