Skip to content

Metaprogramming Patterns

Metaprogramming means writing code that helps create, inspect, or specialize other code.

Metaprogramming means writing code that helps create, inspect, or specialize other code.

In Zig, metaprogramming is mostly built from a few simple tools:

ToolPurpose
comptimeRun code during compilation
comptime T: typePass a type into a function
anytypeLet the compiler infer a generic parameter
inline forExpand repeated code at compile time
@typeInfoInspect a type
@fieldAccess a field by name
@hasDeclCheck whether a type has a declaration
@compileErrorStop compilation with a custom error

Zig does not usually need a separate macro language. The metaprogramming language is Zig itself.

Pattern 1: Type Functions

A type function is a function that returns a type.

fn Box(comptime T: type) type {
    return struct {
        value: T,
    };
}

Usage:

const IntBox = Box(i32);
const BoolBox = Box(bool);

This pattern is the base of Zig generic data structures.

You pass information known at compile time, and the function returns a concrete type.

Pattern 2: Generic Methods

A generated type can contain methods that use the compile-time type.

fn Pair(comptime T: type) type {
    return struct {
        first: T,
        second: T,

        pub fn swap(self: *@This()) void {
            const tmp = self.first;
            self.first = self.second;
            self.second = tmp;
        }
    };
}

Usage:

var p = Pair(i32){
    .first = 10,
    .second = 20,
};

p.swap();

@This() refers to the concrete generated type.

For Pair(i32), it means the Pair(i32) type.

Pattern 3: Compile-Time Validation

Use @compileError to reject unsupported input early.

fn requireInteger(comptime T: type) void {
    switch (@typeInfo(T)) {
        .int => {},
        else => @compileError("expected an integer type"),
    }
}

Usage:

comptime {
    requireInteger(u32);
}

This succeeds.

comptime {
    requireInteger(bool);
}

This fails during compilation.

This pattern is useful in libraries. Instead of letting users get a long confusing type error, you can give them a direct message.

Pattern 4: Type-Based Dispatch

Use switch or if on a type.

fn defaultValue(comptime T: type) T {
    return switch (@typeInfo(T)) {
        .bool => false,
        .int => 0,
        .float => 0.0,
        else => @compileError("unsupported type"),
    };
}

Usage:

const a = defaultValue(bool);
const b = defaultValue(u32);
const c = defaultValue(f64);

The compiler chooses the correct branch.

There is no runtime type check.

Pattern 5: Field Iteration

Use @typeInfo and inline for to inspect struct fields.

fn fieldCount(comptime T: type) usize {
    return switch (@typeInfo(T)) {
        .@"struct" => |info| info.fields.len,
        else => @compileError("expected a struct type"),
    };
}

Usage:

const User = struct {
    id: u64,
    name: []const u8,
    active: bool,
};

const count = fieldCount(User);

The result is 3, known at compile time.

Field iteration is the base for serializers, validators, formatters, and mappers.

Pattern 6: Accessing Fields by Name

@field lets you access a field when the field name is known as data.

const std = @import("std");

fn printFields(comptime T: type, value: T) void {
    switch (@typeInfo(T)) {
        .@"struct" => |info| {
            inline for (info.fields) |field| {
                const field_value = @field(value, field.name);
                std.debug.print("{s} = {}\n", .{ field.name, field_value });
            }
        },
        else => @compileError("expected a struct type"),
    }
}

Usage:

const User = struct {
    id: u64,
    active: bool,
};

pub fn main() void {
    const user = User{
        .id = 1,
        .active = true,
    };

    printFields(User, user);
}

The compile-time loop generates direct field access.

Conceptually, the compiler creates code like this:

std.debug.print("{s} = {}\n", .{ "id", user.id });
std.debug.print("{s} = {}\n", .{ "active", user.active });

Pattern 7: Checking for Declarations

@hasDecl checks whether a type has a declaration.

fn requireInit(comptime T: type) void {
    if (!@hasDecl(T, "init")) {
        @compileError("type must provide init");
    }
}

This is useful when writing generic code that expects a type to provide certain functions.

fn make(comptime T: type) T {
    requireInit(T);
    return T.init();
}

If T has no init, compilation fails.

This is a lightweight way to express an interface-like requirement.

Pattern 8: Generic Functions with anytype

anytype lets the compiler infer the concrete type.

fn printTwice(value: anytype) void {
    const std = @import("std");
    std.debug.print("{} {}\n", .{ value, value });
}

Usage:

printTwice(10);
printTwice(true);

The compiler checks each call separately.

This is useful for small generic helpers where writing comptime T: type would add noise.

For larger APIs, explicit type parameters are often clearer.

Pattern 9: Compile-Time Tables

You can build fixed lookup tables during compilation.

fn makeSquares() [8]u32 {
    comptime var values: [8]u32 = undefined;

    inline for (0..8) |i| {
        values[i] = @intCast(i * i);
    }

    return values;
}

const squares = makeSquares();

The final table is:

.{ 0, 1, 4, 9, 16, 25, 36, 49 }

The work is done during compilation.

At runtime, the program already has the data.

Pattern 10: Compile-Time Configuration

A compile-time flag can remove unused code.

fn Logger(comptime enabled: bool) type {
    return struct {
        pub fn log(message: []const u8) void {
            if (enabled) {
                @import("std").debug.print("{s}\n", .{message});
            }
        }
    };
}

Usage:

const DebugLog = Logger(true);
const SilentLog = Logger(false);

For Logger(false), the compiler can remove the print path.

This is useful for debug logging, feature flags, tracing, and optional checks.

Pattern 11: Static Interface Checks

Zig does not have traditional interfaces, but you can check for required declarations.

fn requireReset(comptime T: type) void {
    if (!@hasDecl(T, "reset")) {
        @compileError("type must define reset");
    }
}

Use it with a generic function:

fn resetOne(value: anytype) void {
    const T = @TypeOf(value.*);
    requireReset(T);
    value.reset();
}

This expects a pointer to a value whose type has a reset method.

Example type:

const Counter = struct {
    value: u32,

    pub fn reset(self: *@This()) void {
        self.value = 0;
    }
};

This works:

var c = Counter{ .value = 10 };
resetOne(&c);

A type without reset fails at compile time.

Pattern 12: Generated Wrappers

You can generate wrappers around a type.

fn OptionalBox(comptime T: type) type {
    return struct {
        value: ?T = null,

        pub fn set(self: *@This(), value: T) void {
            self.value = value;
        }

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

Usage:

var box = OptionalBox(u32){};
box.set(42);

This pattern appears in containers, caches, parsers, and API adapters.

Pattern 13: Struct-Based Configuration

A common Zig style is to pass a compile-time config struct.

fn Buffer(comptime config: anytype) type {
    return struct {
        data: [config.capacity]u8 = undefined,
        len: usize = 0,
    };
}

Usage:

const Small = Buffer(.{
    .capacity = 64,
});

This is clearer than passing many separate compile-time arguments.

You can also validate the config:

fn Buffer(comptime config: anytype) type {
    if (config.capacity == 0) {
        @compileError("capacity must be greater than zero");
    }

    return struct {
        data: [config.capacity]u8 = undefined,
        len: usize = 0,
    };
}

Pattern 14: Reflection-Based Validation

You can inspect a struct and reject fields that do not meet your rules.

fn requireOnlyIntegers(comptime T: type) void {
    switch (@typeInfo(T)) {
        .@"struct" => |info| {
            inline for (info.fields) |field| {
                switch (@typeInfo(field.type)) {
                    .int => {},
                    else => @compileError("all fields must be integers"),
                }
            }
        },
        else => @compileError("expected a struct type"),
    }
}

Usage:

const Point = struct {
    x: i32,
    y: i32,
};

comptime {
    requireOnlyIntegers(Point);
}

This succeeds.

A struct with a []const u8 field would fail.

Pattern 15: Specializing for Performance

Sometimes you can remove runtime checks by moving a choice to compile time.

const Mode = enum {
    checked,
    unchecked,
};

fn add(comptime mode: Mode, a: u32, b: u32) u32 {
    return switch (mode) {
        .checked => a + b,
        .unchecked => a +% b,
    };
}

Usage:

const x = add(.checked, 10, 20);
const y = add(.unchecked, 10, 20);

The compiler knows the mode for each call.

The final code does not need a runtime switch.

Use Metaprogramming Sparingly

Metaprogramming can make code powerful, but it can also make code harder to read.

Prefer simple runtime code when the choice depends on runtime data.

Use metaprogramming when the choice is structural:

types

field names

array sizes

static options

build flags

protocol layouts

fixed tables

Avoid metaprogramming when it only makes ordinary code look clever.

Mental Model

Zig metaprogramming has a simple shape:

fn Tool(comptime input: type) type {
    return struct {
        // generated structure
    };
}

Or:

fn useType(comptime T: type, value: T) void {
    const info = @typeInfo(T);

    switch (info) {
        .@"struct" => |struct_info| {
            inline for (struct_info.fields) |field| {
                // generated field-specific code
            }
        },
        else => @compileError("unsupported type"),
    }
}

The compiler runs the compile-time part, checks it, and emits direct runtime code.

That is the central pattern: use Zig to shape Zig before the program runs.