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
comptimeexpressions and blocks to compute data at build time and surface it at run time. - Introspect types using
@TypeOf,@typeInfo, and@typeNameto implement robust, generic helpers. - Apply
inline fnandinline for/whilejudiciously, 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’sfalse). - Builds a small squares lookup table at compile time using an
inline whileand a comptime index.
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();
}
$ zig run chapters-data/code/15__comptime-and-reflection/comptime_basics.ziga (comptime 2+3) = 5
@inComptime() during runtime: false
squares[0..8): 0,1,4,9,16,25,36,49inline 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.
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.
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();
}
$ zig run chapters-data/code/15__comptime-and-reflection/type_info_introspect.zigtype name: type_info_introspect.Person
fields: 3
0. id: u32
1. name: []const u8
2. active: bool
example: id=42 name=Zig active=trueUse @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.
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@typeInfofield 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:
The introspection API provides:
fields(T): Returns compile-time field information for any struct, union, enum, or error set—iterate withinline forto 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.
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();
}
$ zig run chapters-data/code/15__comptime-and-reflection/inline_for_inline_fn.zigacc=26Inline 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.
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();
}
$ zig run chapters-data/code/15__comptime-and-reflection/has_decl_field_embedfile.zighas 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.
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();
}
$ zig run chapters-data/code/15__comptime-and-reflection/anytype_and_generics.zigsum(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
formatFieldshelper that uses@typeInfoto print any struct’s field names and values. Try it with nested structs and slices. 47 - Build a compile-time computed
sin/coslookup table for integer angles and benchmark againststd.mathcalls in a tight loop. Measure code size and runtime. 50 - Add a
hasToStringcheck: if a typeThas aformatmethod, print with{f}, otherwise print with{any}. Clarify behavior in a short doc comment.
Alternatives & Edge Cases
@inComptime()istruein comptime contexts only; don’t rely on it for runtime behavior switches. Keep such switches in values/parameters.@embedFileincreases binary size; avoid embedding large resources. For configs/logos, it’s great. For datasets, stream from disk or network. 28- Avoid
inline fnon large functions; it can balloon code. Use it on leaf arithmetic helpers or very small combinators where profiling shows wins. 39