about things notes.zzstoatzz.io
10 kB · 236 lines
Markdown
at main

comptime #

written against zig 0.15 through 0.17.

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 info = @typeInfo(Args).@"struct";
    var types: [param_names.len]type = undefined;

    inline for (param_names, 0..) |name, i| {
        for (info.field_names, info.field_types) |field_name, field_type| {
            if (std.mem.eql(u8, field_name, name)) {
                types[i] = field_type;
                break;
            }
        }
    }
    return @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.

this is the 0.17 shape. before 0.17, @typeInfo gave an array of field structs (.fields, each with .name and .type) and the tuple came from std.meta.Tuple; see the 0.17 migration.

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 #

comptime evaluation stops after 1000 backwards branches (loop iterations, recursive calls) by default. @setEvalBranchQuota can only raise that limit: a value below 1000, or below a quota already set, is ignored. so a quota of input.len * 2 does nothing for any input shorter than 500 bytes.

@setEvalBranchQuota(input.len * 100);

a parser's branch count grows with every byte it visits, and helper calls and rescans add more per byte. zql's column scanner exhausts the default somewhere between 170 and 270 bytes of a plain SELECT with no nesting, which is four to six branches per byte. any multiplier below that fails once queries reach realistic lengths. the quota is a ceiling and costs nothing when it goes unused, so set it generously, and test with the longest real query rather than short examples.

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".field_names.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.

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

inline functions and @inComptime #

@inComptime() reports whether the call is being evaluated at comptime, not whether its arguments are comptime-known. an inline fn that branches on it takes the runtime branch when it is called from ordinary code, even when every argument is a literal:

pub inline fn columnIndex(name: []const u8) ?Tag {
    if (@inComptime()) return @intFromEnum(@field(Columns, name));
    return if (std.meta.stringToEnum(Columns, name)) |c| @intFromEnum(c) else null;
}

const i = Q.columnIndex("uri");          // runtime lookup, i is not comptime-known
const j = comptime Q.columnIndex("uri"); // comptime branch, j is comptime-known

the first form fails as soon as the value has to be comptime-known. inline for bounds, array lengths, and an if whose body calls @compileError all need it, and a @compileError under a runtime if is analyzed every time, so it fires whatever the condition is. call sites that need a comptime result have to say comptime explicitly.

generating enums #

@Enum(TagInt, .exhaustive, names, values) builds an enum from comptime slices, which turns a list of column or field names into a type where a misspelled name is a compile error. an exhaustive enum with no fields must be backed by noreturn; zig 0.17 rejects any other empty tag type. a generator that can be handed zero names needs that case on its own path:

const Columns = if (names.len == 0)
    @Enum(noreturn, .exhaustive, &.{}, &.{})
else blk: {
    const TagInt = std.math.IntFittingRange(0, names.len - 1);
    var values: [names.len]TagInt = undefined;
    for (&values, 0..) |*v, i| v.* = i;
    break :blk @Enum(TagInt, .exhaustive, names, &values);
};

IntFittingRange(0, len - 1) underflows when len is 0, so the zero case has to be split off before that line.

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.

tests of an anytype adapter should pass the exact shapes callers pass. a test that binds a pre-coerced const id: []const u8 = ... passes while a real call site that passes @tagName(class) fails to compile, and only at integration time.

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).

the cost of not having one: a client whose query/exec take only comptime sql forces a 5-arm switch over an enum just to call a different sql string per branch, and blocks adopting a sqlite migration runner entirely. a runtime escape hatch fixes both.

comptime inventories #

a top-level const array of structs is a comptime-known inventory: the compiler type-checks every entry, dead fields are impossible, and anything derived from it (routing tables, docs pages, generated clients) is proven in sync with the data at build time because it is the data. the pattern beats registration-at-runtime whenever the full set is known at build time — the array is the single source of truth and inline for fans it out.

sources #

  • zql — src/Query.zig, src/ParseResult.zig; the v0.1.0-alpha.0 upgrade of pub-search, which hit the quota floor, @inComptime at runtime call sites, and empty Columns enums on zig 0.17.0, 2026-10-16
  • zig 0.17 langref — @setEvalBranchQuota, @inComptime
  • pub-search — backend/src/db/Client.zig, RuntimeValue/execRuntime escape hatch