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.

Leave a Comment

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