zig-reflexion-basica

Reflection in Zig with @typeInfo and inline for at comptime

  • 4 min

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", .{}),
    }
}
Copied!

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);
}
Copied!

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 });
Copied!

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 }
}
Copied!

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;
}
Copied!

If you try to pass it an object without health, the program will not compile. It will show your custom error message.