Zig Tagged Unions
A tagged union is a type that can hold one of several different shapes of data, with a built-in tag that always records which shape is currently active. Tagged unions model situations where a value is fundamentally one thing or another — never both at the same time. They are the foundation of type-safe state machines, abstract syntax trees, and event systems.
Why Tagged Unions Over Plain Unions
Plain union (unsafe): Tagged union (safe):
+---------------------+ +----------------------+
| union { | | union(enum) { |
| int_val: i32, | | int_val: i32, |
| float_val: f64, | | float_val: f64, |
| } | | text_val: []u8, |
| | | } |
| No memory of which | | Always knows which |
| field was set. | | field is active. |
| Reading wrong field | | Compiler enforces |
| = undefined behavior| | correct access. |
+---------------------+ +----------------------+
Defining a Tagged Union
const Token = union(enum) {
integer: i64,
float: f64,
identifier: []const u8,
symbol: u8,
eof, // no data, just a tag
};
Token can be: ┌────────────────────────────────┐ │ .integer → holds an i64 │ │ .float → holds an f64 │ │ .identifier → holds a []u8 │ │ .symbol → holds a u8 │ │ .eof → holds nothing │ └────────────────────────────────┘ Only ONE is active at any time.
Creating Tagged Union Values
const t1 = Token{ .integer = 42 };
const t2 = Token{ .float = 3.14 };
const t3 = Token{ .identifier = "main" };
const t4 = Token{ .symbol = '+' };
const t5 = Token.eof;
Switching on a Tagged Union
fn describeToken(tok: Token) void {
switch (tok) {
.integer => |n| std.debug.print("Integer: {d}\n", .{n}),
.float => |f| std.debug.print("Float: {d}\n", .{f}),
.identifier => |s| std.debug.print("Name: {s}\n", .{s}),
.symbol => |c| std.debug.print("Symbol: {c}\n", .{c}),
.eof => std.debug.print("End of file\n", .{}),
}
}
describeToken(t1); // Integer: 42
describeToken(t3); // Name: main
describeToken(t5); // End of file
Tagged Union as a State Machine
State machines describe systems that move through defined states. A tagged union maps perfectly: each variant is one state, and the data inside is specific to that state.
const TrafficLight = union(enum) {
red: struct { seconds_remaining: u8 },
yellow: struct { seconds_remaining: u8 },
green: struct { seconds_remaining: u8 },
};
fn nextState(light: TrafficLight) TrafficLight {
return switch (light) {
.red => |r| .{ .green = .{ .seconds_remaining = 30 } },
.green => |g| .{ .yellow = .{ .seconds_remaining = 5 } },
.yellow => |y| .{ .red = .{ .seconds_remaining = 45 } },
};
}
State transitions:
┌───────┐ nextState ┌────────┐
│ Red │ ─────────────→ │ Green │
└───────┘ └────────┘
↑ │
│ nextState
nextState ↓
│ ┌────────┐
└───────────────── │ Yellow │
└────────┘
Tagged Union for AST Nodes
Abstract syntax trees represent code structure. Each node is one of many possible forms:
const Expr = union(enum) {
number: f64,
variable: []const u8,
binary_op: struct {
op: u8,
left: *const Expr,
right: *const Expr,
},
unary_op: struct {
op: u8,
operand: *const Expr,
},
};
fn evaluate(expr: *const Expr, env: anytype) f64 {
return switch (expr.*) {
.number => |n| n,
.variable => |name| env.get(name),
.binary_op => |op| blk: {
const l = evaluate(op.left, env);
const r = evaluate(op.right, env);
break :blk switch (op.op) {
'+' => l + r,
'-' => l - r,
'*' => l * r,
'/' => l / r,
else => 0,
};
},
.unary_op => |op| blk: {
const v = evaluate(op.operand, env);
break :blk if (op.op == '-') -v else v;
},
};
}
Mutating Tagged Union Fields
var light = TrafficLight{ .red = .{ .seconds_remaining = 45 } };
// Update the seconds in the red state
switch (light) {
.red => |*r| r.seconds_remaining -= 1,
else => {},
}
The |*r| capture with a pointer lets you modify the active field in place. Without the pointer, you get a copy and changes do not affect the original.
Tagged Union Memory Layout
union(enum) {
small: u8, // 1 byte
medium: u32, // 4 bytes
large: [10]u8, // 10 bytes ← largest field
}
Memory layout:
┌──────────┬──────────────────────────┐
│ tag │ data │
│ (enum) │ (size = largest field) │
│ 1–4B │ 10B │
└──────────┴──────────────────────────┘
Total: tag size + max field size (with alignment)
Practical Example: Event System
const std = @import("std");
const Event = union(enum) {
mouse_click: struct { x: i32, y: i32 },
key_press: struct { key: u8, shift: bool },
resize: struct { width: u32, height: u32 },
quit,
};
fn handleEvent(event: Event) void {
switch (event) {
.mouse_click => |click|
std.debug.print("Click at ({d},{d})\n", .{click.x, click.y}),
.key_press => |key|
std.debug.print("Key '{c}' (shift={any})\n", .{key.key, key.shift}),
.resize => |size|
std.debug.print("Resize to {d}x{d}\n", .{size.width, size.height}),
.quit =>
std.debug.print("Quitting.\n", .{}),
}
}
pub fn main() void {
const events = [_]Event{
.{ .mouse_click = .{ .x = 320, .y = 240 } },
.{ .key_press = .{ .key = 'A', .shift = true } },
.{ .resize = .{ .width = 1280, .height = 720 } },
.quit,
};
for (events) |evt| handleEvent(evt);
}
Output:
Click at (320,240) Key 'A' (shift=true) Resize to 1280x720 Quitting.
