comptime

comptime

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: leaflet-search/db/Client.zig

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