zig-skills/references/comptime.md

12 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 declarations are implicitly comptime.

// 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 comptime in 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. Can reference runtime values. Cannot use break to return values.

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
Return value from loop comptime for Only comptime allows break with value
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 {
    const value: T = @bitCast(bytes[0..@sizeOf(T)].*);
    if (comptime native_endian == .big) {
        return value;
    } else {
        return @byteSwap(value);
    }
}

Propagating Across Functions

Use inline fn to propagate comptime conditions to call sites:

// WITHOUT inline: branch exists at runtime
fn maybeLog(comptime enabled: bool, msg: []const u8) void {
    if (enabled) std.debug.print("{s}\n", .{msg});
}

// WITH inline: branch eliminated at each call site
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 { ... }

No I/O at Comptime

// NOT POSSIBLE
const config = comptime std.fs.cwd().readFile("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 No @embedFile or build.zig
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:

  1. Cross-compilation works - comptime sees target, not host
  2. Code is readable - no hidden code generation
  3. Builds are reproducible - no I/O side effects
  4. All API is visible - no dynamic method injection