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.

Leave a Comment

Your email address will not be published. Required fields are marked *