zig-skills/references/comptime.md

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 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 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:

  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