Zig Comptime

Comptime is Zig's system for running code at compile time. Instead of special template syntax or preprocessor macros, you write regular Zig code and mark it with comptime. The compiler runs that code while building your program, and the results become part of the compiled binary — with zero runtime cost.

The Two Phases of a Zig Program

  Phase 1: Compile time
  ┌─────────────────────────────────┐
  │ Zig source code (.zig)          │
  │ comptime code runs here         │ ← @typeInfo, type math,
  │ types are computed              │   array sizes, generics
  │ constants are evaluated         │
  └──────────────┬──────────────────┘
                 │ produces
  Phase 2: Runtime
  ┌──────────────▼──────────────────┐
  │ Machine code runs on CPU        │
  │ No comptime overhead            │ ← user interaction,
  │ Only runtime decisions left     │   file I/O, network
  └─────────────────────────────────┘

Comptime Variables and Constants

const ARRAY_SIZE = 10;             // comptime_int — known at compile
const PI = 3.14159265358979;       // comptime_float

comptime var counter: i32 = 0;    // variable computed at compile time
comptime {
    counter += 1;
    counter += 1;
}
// counter is 2 at compile time

Comptime Function Parameters

A parameter marked comptime must be known at compile time. The compiler runs the function once per unique value of that parameter and generates specialized code for each:

fn makeArray(comptime T: type, comptime size: usize) [size]T {
    var result: [size]T = undefined;
    for (&result, 0..) |*item, i| {
        item.* = @as(T, @intCast(i));
    }
    return result;
}

const ints   = makeArray(i32, 5);   // [0, 1, 2, 3, 4] as i32
const floats = makeArray(f64, 3);   // [0.0, 1.0, 2.0] as f64
  makeArray(i32, 5):        makeArray(f64, 3):
  Called at compile time.   Called at compile time.
  Generates code for        Generates code for
  [5]i32.                   [3]f64.

  Runtime sees only the results — no function call overhead.

Type as a Comptime Parameter

In Zig, types are values at compile time. You pass a type the same way you pass an integer — as a function argument:

fn zeroed(comptime T: type) T {
    return switch (@typeInfo(T)) {
        .Int   => @as(T, 0),
        .Float => @as(T, 0.0),
        .Bool  => false,
        else   => @compileError("zeroed: unsupported type"),
    };
}

const zi = zeroed(i32);   // 0
const zf = zeroed(f64);   // 0.0
const zb = zeroed(bool);  // false
  zeroed(i32) at compile time:
  @typeInfo(i32) → .Int → return 0

  zeroed(f64) at compile time:
  @typeInfo(f64) → .Float → return 0.0

Comptime If — Dead Code Elimination

const IS_RELEASE = true;

fn log(msg: []const u8) void {
    if (comptime IS_RELEASE) return;  // removed entirely in release build
    std.debug.print("[LOG] {s}\n", .{msg});
}

When IS_RELEASE is true, the compiler removes the print statement entirely from the compiled binary — no runtime check, no branch, nothing. This replaces C's #ifdef DEBUG preprocessor approach with regular code that the compiler optimizes away.

Comptime Loops — Code Generation

// Generate a lookup table at compile time
const SQUARES = blk: {
    var table: [10]u32 = undefined;
    for (&table, 0..) |*entry, i| {
        entry.* = i * i;
    }
    break :blk table;
};

// SQUARES = [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]
// Computed once during compilation. Runtime reads are just array lookups.
  Without comptime:          With comptime:
  Runtime computes n*n       Table exists in binary
  every time you need it.    as raw data.
  n calls = n multiplies.    n calls = n memory reads.

@typeInfo — Inspecting Types at Compile Time

const info = @typeInfo(u32);
// info is a union with tag .Int
// info.Int.bits    = 32
// info.Int.signedness = .unsigned

const info2 = @typeInfo(struct { x: f32, y: f32 });
// info2.Struct.fields = [{name:"x", type:f32}, {name:"y", type:f32}]

@typeInfo gives you a complete description of any type at compile time. You can inspect field names, array lengths, function signatures, enum variants, and more — all without running the program.

Generating Code with Comptime

fn printFields(value: anytype) void {
    const T = @TypeOf(value);
    inline for (@typeInfo(T).Struct.fields) |field| {
        std.debug.print("{s} = {any}\n", .{
            field.name,
            @field(value, field.name),
        });
    }
}

const Point = struct { x: f32, y: f32, z: f32 };
const p = Point{ .x = 1.0, .y = 2.5, .z = 0.5 };
printFields(p);

Output:

x = 1.0
y = 2.5
z = 0.5

The inline for over struct fields runs at compile time. The compiler generates separate print calls for each field — the loop is unrolled into straight-line code. No reflection at runtime. No dynamic dispatch. Just fast, direct code.

Comptime Errors

fn mustBeUnsigned(comptime T: type) void {
    const info = @typeInfo(T);
    if (info != .Int or info.Int.signedness != .unsigned) {
        @compileError("mustBeUnsigned requires an unsigned integer type");
    }
}

mustBeUnsigned(u32);   // OK
mustBeUnsigned(i32);   // COMPILE ERROR: "mustBeUnsigned requires..."

Use @compileError to produce a clear, readable error message when a comptime constraint is violated. This is far more helpful than the cryptic type mismatch errors that templates produce in C++.

When to Use Comptime

  Use comptime for:
  ✓ Array sizes that depend on constants
  ✓ Generic functions that work with any type
  ✓ Lookup tables computed from formulas
  ✓ Debug-only code that vanishes in release builds
  ✓ Type-safe serialization and deserialization
  ✓ Compile-time validation of constants

  Do NOT use comptime for:
  ✗ Values known only at runtime (user input, file contents)
  ✗ Replacing simple runtime logic
  ✗ Making code clever at the cost of readability

Leave a Comment

Your email address will not be published. Required fields are marked *