Skip to content

Reflection Systems

Reflection means a program can inspect information about types while the program is being compiled or running.

Reflection means a program can inspect information about types while the program is being compiled or running.

Some languages provide runtime reflection. A program can ask questions like:

What fields does this struct contain?
What is the name of this type?
How many arguments does this function take?

In many languages, this information exists at runtime inside the final executable.

Zig takes a different approach.

Zig reflection is mostly compile-time reflection.

That means the compiler can inspect types while compiling the program. This is faster, safer, and simpler than large runtime reflection systems.

The center of Zig reflection is:

@typeInfo

This builtin lets you inspect the structure of a type.

Why Reflection Matters

Reflection sounds advanced, but it solves practical problems.

Suppose you want to:

serialize structs to JSON
build command-line parsers
generate debug output
write generic containers
automatically validate data
build ORMs or config systems

Without reflection, you often repeat the same information manually.

Example:

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

Then somewhere else:

// Serialize "id"
// Serialize "name"
// Serialize "active"

And somewhere else again:

// Validate "id"
// Validate "name"
// Validate "active"

Reflection lets the compiler inspect the struct fields automatically.

Reflection in Zig Happens at Compile Time

This is one of the most important ideas in Zig.

When Zig reflects on a type, it usually does so during compilation, not while the program is running.

That means:

less runtime overhead
smaller binaries
more optimization opportunities
fewer hidden runtime systems

Reflection in Zig is not a giant object system with runtime metadata everywhere.

Instead, Zig treats types as compile-time values.

The Simplest Reflection Example

Let us inspect a type.

const std = @import("std");

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

pub fn main() void {
    const info = @typeInfo(User);

    std.debug.print("{}\n", .{info});
}
@typeInfo(User)

asks the compiler:

Tell me about the structure of User.

The result is a tagged union describing the type.

For a struct, the result contains information like:

field names
field types
field count
layout information

Understanding @typeInfo

@typeInfo returns a value of type:

std.builtin.Type

This is a large tagged union describing every kind of Zig type.

Examples:

Struct
Enum
Union
Array
Pointer
Optional
Fn
Int
Float
Vector

A type is itself data that the compiler can inspect.

This is a major Zig idea.

Inspecting Struct Fields

Now let us inspect the fields inside a struct.

const std = @import("std");

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

pub fn main() void {
    const info = @typeInfo(User);

    switch (info) {
        .@"struct" => |struct_info| {
            std.debug.print("field count = {}\n", .{
                struct_info.fields.len,
            });

            for (struct_info.fields) |field| {
                std.debug.print(
                    "field: {s}\n",
                    .{field.name},
                );
            }
        },
        else => {},
    }
}

Output:

field count = 3
field: id
field: name
field: active

This is real reflection.

The compiler is exposing the structure of the type to your code.

Why switch Is Needed

Remember that @typeInfo returns a tagged union.

A type might be:

a struct
an enum
a pointer
a function
an integer

So Zig requires you to handle the correct case.

switch (info) {
    .@"struct" => |struct_info| {
        // struct handling
    },
    else => {},
}

This is safer than assuming the type is always a struct.

Accessing Field Types

Each field contains more than just a name.

You can inspect the field type too.

const std = @import("std");

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

pub fn main() void {
    const info = @typeInfo(User);

    switch (info) {
        .@"struct" => |struct_info| {
            for (struct_info.fields) |field| {
                std.debug.print(
                    "{s}: {}\n",
                    .{
                        field.name,
                        field.type,
                    },
                );
            }
        },
        else => {},
    }
}

Possible output:

id: u64
name: []const u8
active: bool

Now the program knows both:

field names
field types

at compile time.

Compile-Time Loops with Reflection

Reflection becomes much more powerful when combined with comptime.

Example:

inline for (struct_info.fields) |field| {
    // compile-time loop
}

An inline for loop runs during compilation.

That means Zig can generate specialized code for every field.

This is how many generic systems work in Zig.

Building a Generic Printer

Let us build a tiny generic debug printer.

const std = @import("std");

fn printStruct(value: anytype) void {
    const T = @TypeOf(value);
    const info = @typeInfo(T);

    switch (info) {
        .@"struct" => |struct_info| {
            std.debug.print("{s} {{ ", .{
                @typeName(T),
            });

            inline for (struct_info.fields, 0..) |field, i| {
                if (i != 0) {
                    std.debug.print(", ", .{});
                }

                std.debug.print("{s}=", .{
                    field.name,
                });

                std.debug.print(
                    "{}",
                    .{@field(value, field.name)},
                );
            }

            std.debug.print(" }}\n", .{});
        },
        else => {
            std.debug.print("not a struct\n", .{});
        },
    }
}

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

pub fn main() void {
    const user = User{
        .id = 42,
        .name = "Ada",
    };

    printStruct(user);
}

Output:

User { id=42, name=Ada }

This function works for many struct types automatically.

Understanding @field

This line is important:

@field(value, field.name)

@field accesses a field dynamically using its name.

Normal access:

value.id

Reflection access:

@field(value, "id")

The second form is useful when field names come from reflection data.

Getting Type Names

Zig provides:

@typeName(T)

Example:

std.debug.print("{s}\n", .{
    @typeName(u32),
});

Output:

u32

For structs:

User
Point
Rectangle

This is useful for debugging, logs, serializers, and generated code.

Reflection with Enums

Reflection also works for enums.

const std = @import("std");

const Color = enum {
    red,
    green,
    blue,
};

pub fn main() void {
    const info = @typeInfo(Color);

    switch (info) {
        .@"enum" => |enum_info| {
            for (enum_info.fields) |field| {
                std.debug.print(
                    "{s}\n",
                    .{field.name},
                );
            }
        },
        else => {},
    }
}

Output:

red
green
blue

The compiler exposes enum metadata too.

Reflection with Tagged Unions

Reflection becomes especially powerful with tagged unions.

const Shape = union(enum) {
    circle: f32,
    rectangle: struct {
        width: f32,
        height: f32,
    },
};

Reflection can inspect:

union fields
tags
payload types

This allows advanced generic systems.

Reflection Is Type-Safe

This is one of Zig’s biggest advantages.

Many runtime reflection systems are string-heavy and weakly typed.

Example from dynamic systems:

"field does not exist"
"method missing"
"wrong runtime type"

Zig reflection happens mostly during compilation.

If you use reflection incorrectly, the compiler usually catches it immediately.

Example:

@field(value, "missing_field")

This produces a compile error if the field does not exist.

Reflection Does Not Mean Dynamic Typing

This is important.

Zig reflection does not turn Zig into a dynamic language.

Types remain static.

The compiler simply exposes information about those types during compilation.

Reflection in Zig is a compile-time metaprogramming tool.

Reflection and Code Generation

Reflection often replaces external code generators.

In some ecosystems, developers write:

schema generators
macro systems
template generators
codegen scripts

Zig often avoids this because compile-time reflection can generate specialized code directly.

Example ideas:

automatic serializers
binary parsers
CLI parsers
network packet encoders
database row mappers

All from type information.

Reflection and Generic Programming

Reflection and generics work together naturally.

Suppose you want:

a generic serializer
a generic validator
a generic equality checker

Reflection lets the compiler inspect arbitrary types.

comptime lets the compiler generate specialized code for those types.

This is one of the central design patterns in advanced Zig.

Reflection Is Not Free

Compile-time reflection has costs.

Heavy metaprogramming can increase:

compile times
compiler memory usage
code complexity

Bad reflection systems can become difficult to understand.

Example:

// Many layers of comptime reflection.
// Hard to debug.
// Hard to read.

A good rule:

Use reflection to remove duplication.
Do not use reflection to hide logic.

Prefer Simple Explicit Code First

Reflection is powerful, but explicit code is often better.

Good use:

automatic serializers
generic containers
debug systems
testing utilities

Bad use:

building a giant hidden framework
replacing simple functions with metaprogramming
making code harder to trace

Zig values clarity.

Reflection should simplify systems, not obscure them.

Reflection and the Zig Philosophy

Reflection in Zig reflects the overall language philosophy.

Zig wants:

explicit behavior
compile-time safety
low runtime cost
simple generated code

Instead of large runtime object systems, Zig exposes compile-time type information directly to the programmer.

You can inspect types, generate code, and build abstractions without hiding what the program is doing.

A Mental Model

Think of reflection like this:

Types are data.
The compiler can inspect that data.
Your compile-time code can react to that data.

This is the foundation of many advanced Zig systems.

When you combine:

@typeInfo
comptime
inline for
@field
@TypeOf
@typeName

you gain the ability to write flexible generic systems while still producing efficient static machine code.