Skip to content

comptime

nate
Jul 8, 20265 min read1 read

comptime

ritten against zig 0.15 and 0.16 (merged).

zig runs code at compile time. not just constants - actual logic, loops, conditionals. you can generate types, validate inputs, and catch errors before your program ever runs.

the payoff: things that would be runtime checks in other languages become compile errors in zig. if your code compiles, certain classes of bugs are impossible.

for a complete example, see zql - it parses SQL at compile time and generates type-safe bindings. typo in a parameter name? compile error.

type-returning functions

the core pattern: a function that takes comptime parameters and returns a type. you're generating a struct definition:

pub fn Wrapper(comptime T: type) type {
    return struct {
        value: T,

        pub fn get(self: @This()) T {
            return self.value;
        }
    };
}

@This() refers to the struct being defined - you need it because the struct doesn't have a name (it's an anonymous struct returned from a function).

generating tuple types from struct fields

sometimes you need to reorder or extract types from a struct. this pattern builds a tuple type by pulling field types in a specific order:

fn BindTuple(comptime Args: type, comptime param_names: []const []const u8) type {
    const fields = @typeInfo(Args).@"struct".fields;
    var types: [param_names.len]type = undefined;

    inline for (param_names, 0..) |name, i| {
        for (fields) |f| {
            if (std.mem.eql(u8, f.name, name)) {
                types[i] = f.type;
                break;
            }
        }
    }
    return std.meta.Tuple(&types);
}

use case: you have named arguments (.{ .name = "alice", .age = 25 }) but need to bind them to positional SQL parameters in a specific order.

see: zql/src/Query.zig#L78

compile-time validation

@compileError stops compilation with a custom message. combine with inline for to check things at compile time:

inline for (required_fields) |name| {
    if (!hasField(T, name)) {
        @compileError("missing required field: " ++ name);
    }
}

if someone forgets a required field, they get a compile error pointing at exactly what's missing.

branch quota

zig limits how much work comptime code can do (prevents infinite loops from hanging compilation). the default is 1000 "backwards branches" (loops, recursion). for complex parsing, you'll hit this:

@setEvalBranchQuota(input.len * 100);

scale it with your input size so small inputs compile fast and large inputs still work.

see: zql/src/parse.zig#L48

simple string validation

count occurrences in a comptime string and compare to something else. useful for SQL placeholder validation:

fn validateArgs(comptime sql: []const u8, comptime ArgsType: type) void {
    const expected = countPlaceholders(sql);
    const provided = @typeInfo(ArgsType).@"struct".fields.len;
    if (expected != provided) {
        @compileError(std.fmt.comptimePrint(
            "SQL has {} placeholders but {} args provided",
            .{ expected, provided },
        ));
    }
}

fn countPlaceholders(comptime sql: []const u8) usize {
    var count: usize = 0;
    for (sql) |c| {
        if (c == '?') count += 1;
    }
    return count;
}

// usage - compile error if mismatch
pub fn query(self: *Client, comptime sql: []const u8, args: anytype) !Result {
    comptime validateArgs(sql, @TypeOf(args));
    // ...
}

the key: comptime on the sql parameter means the string is known at compile time, so you can iterate it and count characters. @TypeOf(args) gets the tuple type, and you can inspect its fields.

see: pub-search/db/Client.zig (pub-search is a search service over atproto publications)

constraints

a few things to know:

  • no allocation at comptime - you can't call an allocator, so use fixed-size arrays
  • no runtime values - everything must be known at compile time (that's the point)
  • comptime code runs during compilation, so complex logic adds build time

detecting string-like types

@TypeOf returns different concrete types for things that all conceptually look like strings:

source@TypeOf result
"hello"*const [5:0]u8
@tagName(MyEnum.foo)*const [3:0]u8
[]const u8 field / parameter[]const u8
[:0]const u8 field / parameter[:0]const u8

== against any one type misses the others:

if (T == []const u8)        // misses string literals AND @tagName
if (T == *const [N:0]u8)    // misses slices, also requires knowing N

walk the typeinfo for a generic predicate:

fn isU8StringLike(comptime T: type) bool {
    const info = @typeInfo(T);
    if (info != .pointer) return false;
    if (info.pointer.size == .slice and info.pointer.child == u8) return true;
    if (info.pointer.size == .one) {
        const child = @typeInfo(info.pointer.child);
        if (child == .array and child.array.child == u8) return true;
    }
    return false;
}

then coerce to []const u8 at the boundary — const s: []const u8 = v; works for all four shapes.

caught me when wrapping anytype args from an external library: tests passed because they used pre-coerced const id: []const u8 = ..., but the real call site bound @tagName(class) and would have compile-failed only at integration time. lesson: when testing an anytype adapter, pass the exact shapes the caller will pass, not pre-coerced equivalents.

comptime sql constrains runtime adoption

a comptime sql: []const u8 parameter (used for placeholder counting via @compileError — see simple string validation) means that function cannot be called with any runtime-built sql. when adopting an external library that builds sql at runtime — migration runners, query builders, dynamic table-name queries — you need a parallel runtime path:

pub fn exec(self: *Client, comptime sql: []const u8, args: anytype) !void { ... }    // existing
pub fn execRuntime(self: *Client, sql: []const u8, args: []const Value) !void { ... } // new

both can share the same wire / http layer underneath via a single private doRequest helper. don't try to route the runtime path through the comptime api via stringification — the type system stops you for a reason (placeholder validation only works at comptime).

real cost: pub-search's db/Client.zig had query/exec taking comptime sql, which forced a 5-arm switch over an enum just to call a different sql string per branch in the timeline endpoint, and would have blocked zug (sqlite migration runner) adoption entirely. adding a runtime escape hatch fixed both.

source: pub-search/db/Client.zigRuntimeValue, execRuntime, queryRuntime.

Did you enjoy this article?

Recommend it — Standard Reader surfaces well-loved writing to more readers across the network.

Across the AtmosphereDiscussions