Reflection with @typeInfo
Reflection means inspecting a type as data.
In ordinary code, you use a type:
const User = struct {
id: u64,
name: []const u8,
};You create values of that type:
const user = User{
.id = 1,
.name = "Ada",
};With reflection, you ask questions about the type itself:
What kind of type is it?
Is it a struct?
What fields does it have?
What are the field names?
What are the field types?
In Zig, the main tool for this is @typeInfo.
The Basic Idea
@typeInfo(T) returns compile-time information about a type.
const info = @typeInfo(User);The value info describes the type User.
Because types are compile-time values, this inspection happens at compile time.
You usually use @typeInfo inside generic functions:
fn inspect(comptime T: type) void {
const info = @typeInfo(T);
_ = info;
}The parameter T is a type known during compilation.
Type Information Is Tagged
The result of @typeInfo is a tagged union.
That means it can describe many different kinds of types, but only one kind at a time.
For example, a type may be:
int
float
bool
pointer
array
struct
enum
union
optional
error union
functionSo you usually use switch:
fn describe(comptime T: type) []const u8 {
return switch (@typeInfo(T)) {
.int => "integer",
.float => "float",
.bool => "bool",
.pointer => "pointer",
.array => "array",
.@"struct" => "struct",
.@"enum" => "enum",
.@"union" => "union",
else => "other",
};
}Notice the quoted names:
.@"struct"
.@"enum"
.@"union"These are needed because struct, enum, and union are Zig keywords.
Inspecting a Struct
Let us inspect this struct:
const User = struct {
id: u64,
name: []const u8,
active: bool,
};Now write a function that logs its field names at compile time:
fn logStructFields(comptime T: type) void {
const info = @typeInfo(T);
switch (info) {
.@"struct" => |struct_info| {
inline for (struct_info.fields) |field| {
@compileLog(field.name);
}
},
else => @compileError("expected a struct type"),
}
}
comptime {
logStructFields(User);
}The compiler prints field names while compiling:
id
name
activeNo runtime reflection is happening here. The compiler is reading the structure of the type before the program runs.
Field Names and Field Types
Each struct field has metadata.
You can inspect the field name and field type:
fn logStructFieldTypes(comptime T: type) void {
const info = @typeInfo(T);
switch (info) {
.@"struct" => |struct_info| {
inline for (struct_info.fields) |field| {
@compileLog(field.name);
@compileLog(@typeName(field.type));
}
},
else => @compileError("expected a struct type"),
}
}For the User type, this gives information like:
id
u64
name
[]const u8
active
boolThis is useful when writing generic tools.
For example, a serializer can inspect fields and generate code that writes each field.
A validator can inspect fields and generate checks.
A command-line parser can inspect config fields and generate flags.
Getting a Field Value with @field
Reflection becomes more useful when combined with @field.
@field(value, name) accesses a field by name.
Example:
const std = @import("std");
fn printStruct(comptime T: type, value: T) void {
const info = @typeInfo(T);
switch (info) {
.@"struct" => |struct_info| {
inline for (struct_info.fields) |field| {
const field_value = @field(value, field.name);
std.debug.print("{s} = {}\n", .{ field.name, field_value });
}
},
else => @compileError("expected a struct type"),
}
}Usage:
const User = struct {
id: u64,
active: bool,
};
pub fn main() void {
const user = User{
.id = 1,
.active = true,
};
printStruct(User, user);
}Output:
id = 1
active = trueThe loop over fields is compile-time. The actual printing happens at runtime.
That distinction matters.
The compiler generates code similar to:
std.debug.print("{s} = {}\n", .{ "id", user.id });
std.debug.print("{s} = {}\n", .{ "active", user.active });Compile-Time Reflection, Runtime Work
Reflection with @typeInfo is usually compile-time reflection.
The compiler uses reflection to generate normal runtime code.
This is different from languages where reflection happens dynamically while the program runs.
In Zig, you normally do not carry a runtime type object around. You inspect the type at compile time and generate direct code.
That gives you two benefits:
The generated runtime code can be fast.
Bad types can be rejected during compilation.
Rejecting Unsupported Field Types
Suppose you only want to print fields of simple types.
fn isPrintable(comptime T: type) bool {
return switch (@typeInfo(T)) {
.bool, .int, .float => true,
.pointer => |ptr| ptr.size == .slice and ptr.child == u8,
else => false,
};
}Now use it in a struct printer:
fn requirePrintableStruct(comptime T: type) void {
const info = @typeInfo(T);
switch (info) {
.@"struct" => |struct_info| {
inline for (struct_info.fields) |field| {
if (!isPrintable(field.type)) {
@compileError("field is not printable: " ++ field.name);
}
}
},
else => @compileError("expected a struct type"),
}
}This catches unsupported types at compile time.
If a struct has a field your function cannot handle, the program fails to compile with a clear error.
Inspecting Arrays
@typeInfo can also inspect arrays.
fn describeArray(comptime T: type) void {
switch (@typeInfo(T)) {
.array => |array_info| {
@compileLog(array_info.len);
@compileLog(@typeName(array_info.child));
},
else => @compileError("expected an array type"),
}
}Usage:
comptime {
describeArray([4]u8);
}This tells the compiler:
length: 4
child type: u8An array type contains both its length and its element type.
That is why [4]u8 and [8]u8 are different types.
Inspecting Pointers and Slices
Pointers also have type information.
fn describePointer(comptime T: type) void {
switch (@typeInfo(T)) {
.pointer => |ptr_info| {
@compileLog(ptr_info.size);
@compileLog(@typeName(ptr_info.child));
},
else => @compileError("expected a pointer type"),
}
}Examples:
comptime {
describePointer(*u8);
describePointer([]const u8);
}The pointer info tells you whether the type is a single-item pointer, many-item pointer, slice, or C pointer. It also tells you the child type.
For *u8, the child type is u8.
For []const u8, the child type is also u8, but the pointer shape is different because a slice carries a pointer and a length.
Inspecting Enums
Enums can also be inspected.
const Color = enum {
red,
green,
blue,
};Use @typeInfo:
fn logEnumTags(comptime T: type) void {
switch (@typeInfo(T)) {
.@"enum" => |enum_info| {
inline for (enum_info.fields) |field| {
@compileLog(field.name);
}
},
else => @compileError("expected an enum type"),
}
}Usage:
comptime {
logEnumTags(Color);
}The compiler logs:
red
green
blueThis is useful for building enum parsers, enum formatters, and lookup tables.
Reflection and Generics
Reflection is most useful when combined with generics.
A generic function can accept any type:
fn encode(comptime T: type, value: T) void {
_ = value;
switch (@typeInfo(T)) {
.@"struct" => {
// encode fields
},
.int => {
// encode integer
},
.bool => {
// encode boolean
},
else => @compileError("unsupported type"),
}
}The function body can inspect T, choose a path, and generate specialized code.
This is how many Zig libraries avoid runtime reflection while still supporting flexible APIs.
Reflection Is Not Magic
@typeInfo only tells you what the compiler knows about the type.
It does not inspect arbitrary runtime values.
For example, this makes sense:
const info = @typeInfo(User);But this does not:
var user = User{ .id = 1, .name = "Ada", .active = true };
const info = @typeInfo(user);@typeInfo expects a type, not a value.
Use:
const info = @typeInfo(@TypeOf(user));Here, @TypeOf(user) gets the type of the value, then @typeInfo inspects that type.
A Practical Example: Counting Struct Fields
Here is a small useful function:
fn fieldCount(comptime T: type) usize {
return switch (@typeInfo(T)) {
.@"struct" => |struct_info| struct_info.fields.len,
else => @compileError("expected a struct type"),
};
}Usage:
const User = struct {
id: u64,
name: []const u8,
active: bool,
};
const count = fieldCount(User);The result is:
3The compiler knows the answer before runtime.
A Practical Example: Checking for a Field
You can check whether a struct has a field with a given name:
fn hasField(comptime T: type, comptime name: []const u8) bool {
return switch (@typeInfo(T)) {
.@"struct" => |struct_info| blk: {
inline for (struct_info.fields) |field| {
if (std.mem.eql(u8, field.name, name)) {
break :blk true;
}
}
break :blk false;
},
else => false,
};
}Usage:
const std = @import("std");
const User = struct {
id: u64,
name: []const u8,
};
const a = hasField(User, "id");
const b = hasField(User, "email");a is true.
b is false.
This kind of helper is useful in generic code.
Mental Model
@typeInfo lets you turn a type into compile-time data.
Then you can inspect that data with normal Zig code.
The usual pattern is:
fn someGenericFunction(comptime T: type, value: T) void {
const info = @typeInfo(T);
switch (info) {
.@"struct" => |struct_info| {
inline for (struct_info.fields) |field| {
// generate field-specific code
}
},
else => @compileError("unsupported type"),
}
}That is the heart of Zig reflection.
You do not ask the running program to discover types dynamically. You ask the compiler to inspect known types and produce direct code.