Zig Unions
A union is a type that can hold one of several different types, but only one at a time. Where a struct holds all its fields simultaneously, a union holds exactly one field at any moment. Think of a union as a shape-shifting container: it takes the form of whichever type you put into it, and the memory size matches the largest possible type it can hold.
The Union Model
Struct (holds ALL fields at once):
+--------+--------+--------+
| name | age | score |
+--------+--------+--------+
8B 1B 4B = 13B total
Union (holds ONE field at a time):
+------------------------+
| name OR age OR score |
+------------------------+
Uses the size of the LARGEST field
max(8B, 1B, 4B) = 8B total
Defining a Union
const Measurement = union {
meters: f64,
kilograms: f64,
celsius: f32,
};
A plain union can store any one of these fields. Zig does not track which field is currently active — that is your responsibility. Reading the wrong field is undefined behavior.
var m = Measurement{ .meters = 1.8 };
std.debug.print("Height: {d}m\n", .{m.meters}); // correct
// m.celsius ← undefined behavior! do not do this
Tagged Unions — Safe Unions
A tagged union pairs the union with an enum that tracks which field is currently active. This is the correct way to use unions in Zig — the tag tells you what the union currently contains:
const ShapeTag = enum { circle, rectangle, triangle };
const Shape = union(ShapeTag) {
circle: f64, // radius
rectangle: struct { w: f64, h: f64 },
triangle: struct { base: f64, height: f64 },
};
Shape can be: ┌──────────────────────────────┐ │ circle → stores: radius │ │ rectangle → stores: w, h │ │ triangle → stores: base, h │ └──────────────────────────────┘ Only ONE active at any time. The tag always says which one.
Creating Tagged Union Values
const s1 = Shape{ .circle = 5.0 };
const s2 = Shape{ .rectangle = .{ .w = 4.0, .h = 3.0 } };
const s3 = Shape{ .triangle = .{ .base = 6.0, .height = 4.0 } };
Switching on a Tagged Union
A switch on a tagged union captures the active field's value automatically:
fn area(shape: Shape) f64 {
return switch (shape) {
.circle => |r| std.math.pi * r * r,
.rectangle => |rec| rec.w * rec.h,
.triangle => |tri| 0.5 * tri.base * tri.height,
};
}
const c = Shape{ .circle = 3.0 };
std.debug.print("Area: {d:.2}\n", .{area(c)}); // 28.27
shape = .circle, radius = 3.0
|
switch matches .circle
|
captures r = 3.0
|
π × 3² = 28.27
The switch is exhaustive — if you add a new shape variant and forget to handle it in the switch, the compiler reports an error immediately. No silent unhandled cases.
Checking the Active Tag
const val = Shape{ .rectangle = .{ .w = 5.0, .h = 2.0 } };
if (val == .rectangle) {
std.debug.print("It is a rectangle\n", .{});
}
// Or use the tag directly:
const tag = @as(ShapeTag, val);
std.debug.print("Tag: {s}\n", .{@tagName(tag)}); // "rectangle"
Union Without an Explicit Enum Tag
When you write union(enum), Zig generates the tag enum automatically from the field names — you do not need to define a separate enum:
const Value = union(enum) {
int: i64,
float: f64,
text: []const u8,
flag: bool,
};
const v1 = Value{ .int = 42 };
const v2 = Value{ .float = 3.14 };
const v3 = Value{ .text = "hello" };
const v4 = Value{ .flag = true };
fn describe(v: Value) void {
switch (v) {
.int => |n| std.debug.print("Integer: {d}\n", .{n}),
.float => |f| std.debug.print("Float: {d}\n", .{f}),
.text => |s| std.debug.print("Text: {s}\n", .{s}),
.flag => |b| std.debug.print("Boolean: {}\n", .{b}),
}
}
describe(v1) → "Integer: 42" describe(v2) → "Float: 3.14" describe(v3) → "Text: hello" describe(v4) → "Boolean: true"
Practical Example: Expression Evaluator
Tagged unions shine in scenarios like abstract syntax trees or expression evaluators, where a node can be one of several different things:
const std = @import("std");
const Expr = union(enum) {
number: f64,
add: struct { left: f64, right: f64 },
mul: struct { left: f64, right: f64 },
};
fn evaluate(e: Expr) f64 {
return switch (e) {
.number => |n| n,
.add => |op| op.left + op.right,
.mul => |op| op.left * op.right,
};
}
pub fn main() void {
const e1 = Expr{ .number = 7.0 };
const e2 = Expr{ .add = .{ .left = 3.0, .right = 4.0 } };
const e3 = Expr{ .mul = .{ .left = 6.0, .right = 7.0 } };
std.debug.print("{d}\n", .{evaluate(e1)}); // 7
std.debug.print("{d}\n", .{evaluate(e2)}); // 7
std.debug.print("{d}\n", .{evaluate(e3)}); // 42
}
Memory Size of a Union
const Example = union(enum) {
small: u8, // 1 byte
medium: u32, // 4 bytes
large: u64, // 8 bytes ← determines union size
};
// @sizeOf(Example) = 8 bytes (for the data)
// + bytes for the tag
The union always allocates enough memory for the largest possible field. Even if you store a u8, the memory block is sized for a u64. This is the trade-off: flexibility in what you store, but always paying the cost of the largest option.
