The reflection in Zig is the ability to inspect types during compilation. In other languages, reflection usually means a program examines itself at runtime; in Zig, it happens before the binary is generated.
In dynamic languages, this inspection typically occurs during execution. Other static languages resort to metadata, templates, or code generation.
In Zig, reflection uses information available during compilation and does not need to preserve field metadata to query it at runtime.
The compiler analyzes the type and generates specific code for each case. This avoids dynamic lookups, although the generated code does occupy space in the binary.
Inspecting a type with @typeInfo
Everything starts with the built-in function @typeInfo(T).
If you pass it a type T, it returns a Tagged Union that describes in detail what that type is. Is it an integer? Is it a function? Is it a struct? If it is a struct, what fields does it have?
const std = @import("std");
const User = struct {
id: u32,
name: []const u8,
admin: bool,
};
pub fn main() void {
// Obtain the type information
const info = @typeInfo(User);
// Check what it is
switch (info) {
.@"struct" => |s| {
std.debug.print("It is a struct with {d} fields.\n", .{s.fields.len});
},
.int => std.debug.print("It is an integer.\n", .{}),
else => std.debug.print("It is something else.\n", .{}),
}
}Iterating fields with inline for
Suppose we want to write a function printAll that takes any struct and prints its field names and their values.
We cannot use a normal for, because the field names ("id", "name") are only known at compile time. We need inline for.
Additionally, we need a way to access a field’s value using its name as a string. For that, @field(object, "name") exists.
Let’s build it step by step:
fn printStruct(obj: anytype) void {
// 1. Get the type of the object
const T = @TypeOf(obj);
// 2. Get the type info
const info = @typeInfo(T);
// 3. Validate that it is a struct (at compile time)
if (info != .@"struct") {
@compileError("printStruct only accepts structs");
}
std.debug.print("Struct: {s}\n", .{@typeName(T)});
// 4. Iterate over the fields (LOOP UNROLLING)
inline for (info.@"struct".fields) |field| {
// 'field' contains metadata: name, type, offset...
// Access the value at runtime
const value = @field(obj, field.name);
std.debug.print(" - {s}: {any}\n", .{ field.name, value });
}
}
pub fn main() void {
const u = User{ .id = 1, .name = "Luis", .admin = true };
// It works automatically!
printStruct(u);
}What did the compiler do?
Conceptually, the inline for produces for User a result equivalent to this code:
// Equivalent code after specializing the function:
std.debug.print("Struct: User\n", .{});
std.debug.print(" - id: {any}\n", .{ u.id });
std.debug.print(" - name: {any}\n", .{ u.name });
std.debug.print(" - admin: {any}\n", .{ u.admin });There is no need to traverse field names during execution: each access is resolved at compile time.
Example: simplified JSON serializer
Reflection allows building serializers for structs without manually writing access to each field.
Let’s make a simplified version to understand the idea:
fn toJson(obj: anytype) void {
const T = @TypeOf(obj);
const info = @typeInfo(T);
std.debug.print("{{", .{}); // Open JSON {
inline for (info.@"struct".fields, 0..) |field, i| {
const value = @field(obj, field.name);
// Add a comma if it's not the first
if (i > 0) std.debug.print(", ", .{});
// Print "key": value
std.debug.print("\"{s}\": ", .{field.name});
// Here we should detect if 'value' is a string or number
// To simplify, we use {any}
if (field.type == []const u8) {
std.debug.print("\"{s}\"", .{value});
} else {
std.debug.print("{any}", .{value});
}
}
std.debug.print("}}\n", .{}); // Close JSON }
}Calling toJson(u) gives us:
{"id": 1, "name": "Luis", "admin": true}
This example only illustrates reflection: it does not escape characters in strings nor covers all JSON types. For real data, use std.json.
Validating capabilities with @hasField
Reflection is also useful for imposing constraints on generic types.
Imagine you want a function that accepts any object, provided it has a field named health.
fn heal(entity: anytype) void {
const T = @TypeOf(entity.*);
// Check if it has the field "health"
const has_health = @hasField(T, "health");
if (!has_health) {
@compileError("The entity must have a 'health' field");
}
// The call must pass a mutable pointer: heal(&player)
entity.health += 10;
}If you try to pass it an object without health, the program will not compile. It will show your custom error message.