Chapter 15Comptime And Reflection

Comptime & Reflection

Overview

Zig lets you execute plain Zig at compile time. That single, quiet idea unlocks a lot: generate lookup tables, specialize code based on types or values, validate invariants before the program is ever run, and write generic utilities without macros or a separate metaprogramming language. Reflection completes the picture: with @TypeOf, @typeInfo, and friends, code can inspect types and construct behavior adaptively.

This chapter is a practitioner’s tour of compile-time execution and reflection in Zig 0.15.2. We’ll build small, self-contained examples you can run directly. Along the way, we’ll discuss what runs when (compile vs. run), how to keep code readable and fast, and when to prefer explicit parameters over clever reflection. For more detail, see meta.zig.

Learning Goals

  • Use comptime expressions and blocks to compute data at build time and surface it at run time.
  • Introspect types using @TypeOf, @typeInfo, and @typeName to implement robust, generic helpers.
  • Apply inline fn and inline for/while judiciously, understanding code-size and performance trade-offs. 37
  • Detect declarations and fields with @hasDecl, @hasField, and embed assets with @embedFile. 19

Compile-time basics: data now, print later

Compile-time work is just ordinary Zig evaluated earlier. The example below:

  • Evaluates an expression at compile time.
  • Checks @inComptime() during runtime (it’s false).
  • Builds a small squares lookup table at compile time using an inline while and a comptime index.
Zig
const std = @import("std");

fn stdout() *std.Io.Writer {
    // Buffered stdout writer per Zig 0.15.2 (Writergate)
    // We keep the buffer static so it survives for main's duration.
    const g = struct {
        var buf: [1024]u8 = undefined;
        var w = std.fs.File.stdout().writer(&buf);
    };
    return &g.w.interface;
}

// Compute a tiny lookup table at compile time; print at runtime.
fn squaresTable(comptime N: usize) [N]u64 {
    var out: [N]u64 = undefined;
    comptime var i: usize = 0;
    inline while (i < N) : (i += 1) {
        out[i] = @as(u64, i) * @as(u64, i);
    }
    return out;
}

pub fn main() !void {
    const out = stdout();

    // Basic comptime evaluation
    const a = comptime 2 + 3; // evaluated at compile time
    try out.print("a (comptime 2+3) = {}\n", .{a});

    // @inComptime reports whether we are currently executing at compile-time
    const during_runtime = @inComptime();
    try out.print("@inComptime() during runtime: {}\n", .{during_runtime});

    // Generate a squares table at compile time
    const table = squaresTable(8);
    try out.print("squares[0..8): ", .{});
    var i: usize = 0;
    while (i < table.len) : (i += 1) {
        if (i != 0) try out.print(",", .{});
        try out.print("{}", .{table[i]});
    }
    try out.print("\n", .{});

    try out.flush();
}
Run
Shell
$ zig run chapters-data/code/15__comptime-and-reflection/comptime_basics.zig
Output
Shell
a (comptime 2+3) = 5
@inComptime() during runtime: false
squares[0..8): 0,1,4,9,16,25,36,49

inline while requires the condition to be known at compile time. Use a comptime var index for unrolled loops. Prefer ordinary loops unless you have a measured reason to unroll.

How the compiler tracks comptime values

When you write comptime code, the compiler must determine which allocations and values are fully known at compile time. This tracking uses a mechanism in semantic analysis (Sema) that monitors all stores to allocated memory.

graph TB subgraph "Key Structures" COMPTIMEALLOC["ComptimeAlloc<br/>val, is_const, alignment"] MAYBECOMPTIMEALLOC["MaybeComptimeAlloc<br/>runtime_index, stores[]"] BASEALLOC["base_allocs map<br/>derived ptr → base alloc"] end subgraph "Lifecycle" RUNTIMEALLOC["Runtime alloc instruction"] STORES["Store operations tracked"] MAKEPTRCONST["make_ptr_const instruction"] COMPTIMEVALUE["Determine comptime value"] end subgraph "MaybeComptimeAlloc Tracking" STORELIST["stores: MultiArrayList<br/>inst, src"] RUNTIMEINDEXFIELD["runtime_index<br/>Allocation point"] end subgraph "ComptimeAlloc Fields" VAL["val: MutableValue<br/>Current value"] ISCONST["is_const: bool<br/>Immutable after init"] ALIGNMENT["alignment<br/>Pointer alignment"] RUNTIMEINDEXALLOC["runtime_index<br/>Creation point"] end RUNTIMEALLOC --> MAYBECOMPTIMEALLOC MAYBECOMPTIMEALLOC --> STORELIST STORELIST --> STORES STORES --> MAKEPTRCONST MAKEPTRCONST --> COMPTIMEVALUE COMPTIMEVALUE --> COMPTIMEALLOC COMPTIMEALLOC --> VAL COMPTIMEALLOC --> ISCONST COMPTIMEALLOC --> ALIGNMENT COMPTIMEALLOC --> RUNTIMEINDEXALLOC BASEALLOC -.->|"tracks"| RUNTIMEALLOC

When the compiler encounters an allocation during semantic analysis, it creates a MaybeComptimeAlloc entry to track all stores. If any store depends on runtime values or conditions, the allocation cannot be known at comptime and the entry is discarded. If all stores are known at comptime when the pointer becomes const, the compiler applies all stores at compile time and creates a ComptimeAlloc with the final value. This mechanism enables the compiler to evaluate complex initialization patterns at compile time while ensuring correctness. For implementation details, see Sema.zig.

Reflection: , , and friends

Reflection lets you write “generic-but-precise” code. Here we examine a struct and print its fields and their types, then construct a value in the usual way.

Zig
const std = @import("std");

fn stdout() *std.Io.Writer {
    const g = struct {
        var buf: [2048]u8 = undefined;
        var w = std.fs.File.stdout().writer(&buf);
    };
    return &g.w.interface;
}

const Person = struct {
    id: u32,
    name: []const u8,
    active: bool = true,
};

pub fn main() !void {
    const out = stdout();

    // Reflect over Person using @TypeOf and @typeInfo
    const T = Person;
    try out.print("type name: {s}\n", .{@typeName(T)});

    const info = @typeInfo(T);
    switch (info) {
        .@"struct" => |s| {
            try out.print("fields: {d}\n", .{s.fields.len});
            inline for (s.fields, 0..) |f, idx| {
                try out.print("  {d}. {s}: {s}\n", .{ idx, f.name, @typeName(f.type) });
            }
        },
        else => try out.print("not a struct\n", .{}),
    }

    // Use reflection to initialize a default instance (here trivial)
    const p = Person{ .id = 42, .name = "Zig" };
    try out.print("example: id={} name={s} active={}\n", .{ p.id, p.name, p.active });

    try out.flush();
}
Run
Shell
$ zig run chapters-data/code/15__comptime-and-reflection/type_info_introspect.zig
Output
Shell
type name: type_info_introspect.Person
fields: 3
  0. id: u32
  1. name: []const u8
  2. active: bool
example: id=42 name=Zig active=true

Use @typeInfo(T) at compile time to derive implementations (formatters, serializers, adapters). Keep the result in a local const for readability.

Type decomposition with

Beyond @typeInfo, the std.meta module provides specialized functions for extracting component types from composite types. These utilities make generic code cleaner by avoiding manual @typeInfo inspection.

graph TB subgraph "Type Extractors" CHILD["Child(T)"] ELEM["Elem(T)"] SENTINEL["sentinel(T)"] TAG["Tag(T)"] ACTIVETAG["activeTag(union)"] end subgraph "Input Types" ARRAY["array"] VECTOR["vector"] POINTER["pointer"] OPTIONAL["optional"] UNION["union"] ENUM["enum"] end ARRAY --> CHILD VECTOR --> CHILD POINTER --> CHILD OPTIONAL --> CHILD ARRAY --> ELEM VECTOR --> ELEM POINTER --> ELEM ARRAY --> SENTINEL POINTER --> SENTINEL UNION --> TAG ENUM --> TAG UNION --> ACTIVETAG

Key type extraction functions:

  • Child(T): Extracts the child type from arrays, vectors, pointers, and optionals—useful for generic functions operating on containers.
  • Elem(T): Gets the element type from memory span types (arrays, slices, pointers)—cleaner than manual @typeInfo field access.
  • sentinel(T): Returns the sentinel value, if present, enabling generic handling of null-terminated data.
  • Tag(T): Gets the tag type from enums and unions for switch-based dispatch.
  • activeTag(u): Returns the active tag of a union value at runtime.

These functions compose well: std.meta.Child(std.meta.Child(T)) extracts the element type from [][]u8. Use them to write generic algorithms that adapt to type structure without verbose switch (@typeInfo(T)) blocks. meta.zig

Field and declaration introspection

For structured access to container internals, std.meta provides higher-level alternatives to manual @typeInfo navigation:

graph TB subgraph "Container Introspection" FIELDS["fields(T)"] FIELDINFO["fieldInfo(T, field)"] FIELDNAMES["fieldNames(T)"] TAGS["tags(T)"] FIELDENUM["FieldEnum(T)"] end subgraph "Declaration Introspection" DECLARATIONS["declarations(T)"] DECLINFO["declarationInfo(T, name)"] DECLENUM["DeclEnum(T)"] end subgraph "Applicable Types" STRUCT["struct"] UNION["union"] ENUMP["enum"] ERRORSET["error_set"] end STRUCT --> FIELDS UNION --> FIELDS ENUMP --> FIELDS ERRORSET --> FIELDS STRUCT --> DECLARATIONS UNION --> DECLARATIONS ENUMP --> DECLARATIONS FIELDS --> FIELDINFO FIELDS --> FIELDNAMES FIELDS --> FIELDENUM ENUMP --> TAGS

The introspection API provides:

  • fields(T): Returns compile-time field information for any struct, union, enum, or error set—iterate with inline for to process each field.
  • fieldInfo(T, field): Gets detailed information (name, type, default value, alignment) for a specific field.
  • FieldEnum(T): Creates an enum with variants for each field name, enabling switch-based field dispatch.
  • declarations(T): Returns compile-time declaration info for functions and constants in a type—useful for finding optional interface methods.

Example pattern: inline for (std.meta.fields(MyStruct)) |field| { …​ } lets you write generic serialization, formatting, or comparison functions without hand-coding field access. The FieldEnum(T) helper is particularly useful for switch statements over field names. meta.zig

Inline functions and inline loops: power and cost

inline fn forces inlining, and inline for unrolls compile-time-known iterations. Both increase code size. Use them when you’ve profiled and determined a hot path benefits from unrolling or call-overhead elimination.

Zig
const std = @import("std");

fn stdout() *std.Io.Writer {
    const g = struct {
        var buf: [1024]u8 = undefined;
        var w = std.fs.File.stdout().writer(&buf);
    };
    return &g.w.interface;
}

// An inline function; the compiler is allowed to inline automatically too,
// but `inline` forces it (use sparingly—can increase code size).
inline fn mulAdd(a: u64, b: u64, c: u64) u64 {
    return a * b + c;
}

pub fn main() !void {
    const out = stdout();

    // inline for: unroll a small loop at compile time
    var acc: u64 = 0;
    inline for (.{ 1, 2, 3, 4 }) |v| {
        acc = mulAdd(acc, 2, v); // (((0*2+1)*2+2)*2+3)*2+4
    }
    try out.print("acc={}\n", .{acc});

    // demonstrate that `inline` is not magic; it's a trade-off
    // prefer profiling for hot paths before forcing inline.
    try out.flush();
}
Run
Shell
$ zig run chapters-data/code/15__comptime-and-reflection/inline_for_inline_fn.zig
Output
Shell
acc=26

Inline is not a performance cheat code. It trades instruction cache and binary size for potential speed. Measure before and after. 39

Capabilities: , , and

Compile-time capability tests let you adapt to types without overfitting APIs. Asset embedding keeps small resources close to the code with no runtime I/O.

Zig
const std = @import("std");

fn stdout() *std.Io.Writer {
    const g = struct {
        var buf: [1024]u8 = undefined;
        var w = std.fs.File.stdout().writer(&buf);
    };
    return &g.w.interface;
}

const WithStuff = struct {
    x: u32,
    pub const message: []const u8 = "compile-time constant";
    pub fn greet() []const u8 {
        return "hello";
    }
};

pub fn main() !void {
    const out = stdout();

    // Detect declarations and fields at comptime
    comptime {
        if (!@hasDecl(WithStuff, "greet")) {
            @compileError("missing greet decl");
        }
        if (!@hasField(WithStuff, "x")) {
            @compileError("missing field x");
        }
    }

    // @embedFile: include file contents in the binary at build time
    const embedded = @embedFile("hello.txt");

    try out.print("has greet: {}\n", .{@hasDecl(WithStuff, "greet")});
    try out.print("has field x: {}\n", .{@hasField(WithStuff, "x")});
    try out.print("message: {s}\n", .{WithStuff.message});
    try out.print("embedded:\n{s}", .{embedded});
    try out.flush();
}
Run
Shell
$ zig run chapters-data/code/15__comptime-and-reflection/has_decl_field_embedfile.zig
Output
Shell
has greet: true
has field x: true
message: compile-time constant
embedded:
Hello from @embedFile!
This text is compiled into the binary at build time.

Place assets next to the source that uses them and reference with a relative path in @embedFile. For larger assets or user-supplied data, prefer runtime I/O. 28

and explicit type parameters: pragmatic generics

Zig’s generics are just functions with comptime parameters. Use explicit type parameters for clarity; use anytype in leaf helpers that forward types. Reflection (@TypeOf, @typeName) helps with diagnostics when you accept flexible inputs.

Zig
const std = @import("std");

fn stdout() *std.Io.Writer {
    const g = struct {
        var buf: [2048]u8 = undefined;
        var w = std.fs.File.stdout().writer(&buf);
    };
    return &g.w.interface;
}

// A generic function that accepts any element type and sums a slice.
// We use reflection to print type info at runtime.
pub fn sum(comptime T: type, slice: []const T) T {
    var s: T = 0;
    var i: usize = 0;
    while (i < slice.len) : (i += 1) s += slice[i];
    return s;
}

pub fn describeAny(x: anytype) void {
    const T = @TypeOf(x);
    const out = stdout();
    out.print("value of type {s}: ", .{@typeName(T)}) catch {};
    // best-effort print
    out.print("{any}\n", .{x}) catch {};
}

pub fn main() !void {
    const out = stdout();

    // Explicit type parameter
    const a = [_]u32{ 1, 2, 3, 4 };
    const s1 = sum(u32, &a);
    try out.print("sum(u32,[1,2,3,4]) = {}\n", .{s1});

    // Inferred by helper that forwards T
    const b = [_]u64{ 10, 20 };
    const s2 = sum(u64, &b);
    try out.print("sum(u64,[10,20]) = {}\n", .{s2});

    // anytype descriptor
    describeAny(@as(u8, 42));
    describeAny("hello");

    try out.flush();
}
Run
Shell
$ zig run chapters-data/code/15__comptime-and-reflection/anytype_and_generics.zig
Output
Shell
sum(u32,[1,2,3,4]) = 10
sum(u64,[10,20]) = 30
value of type u8: 42
value of type *const [5:0]u8: { 104, 101, 108, 108, 111 }

Prefer explicit comptime T: type parameters for public APIs; restrict anytype to helpers that transparently forward the concrete type and don’t constrain semantics.

Notes & Caveats

  • Compile-time execution runs in the compiler; be mindful of complexity. Keep heavy work out of tight incremental loops to preserve fast rebuilds. 38
  • Inline loops require compile-time-known bounds. When in doubt, use runtime loops and let the optimizer do its job. 39
  • Reflection is powerful but can obscure control flow. Prefer straightforward parameters for clarity, and reflect only where ergonomics justify it. 36

Exercises

  • Write a formatFields helper that uses @typeInfo to print any struct’s field names and values. Try it with nested structs and slices. 47
  • Build a compile-time computed sin/cos lookup table for integer angles and benchmark against std.math calls in a tight loop. Measure code size and runtime. 50
  • Add a hasToString check: if a type T has a format method, print with {f}, otherwise print with {any}. Clarify behavior in a short doc comment.

Alternatives & Edge Cases

  • @inComptime() is true in comptime contexts only; don’t rely on it for runtime behavior switches. Keep such switches in values/parameters.
  • @embedFile increases binary size; avoid embedding large resources. For configs/logos, it’s great. For datasets, stream from disk or network. 28
  • Avoid inline fn on large functions; it can balloon code. Use it on leaf arithmetic helpers or very small combinators where profiling shows wins. 39

Help make this chapter better.

Found a typo, rough edge, or missing explanation? Open an issue or propose a small improvement on GitHub.