Lesson 8 / 25

Enums, Tagged Unions and Exhaustive switch

Model alternatives with enums and tagged unions.

Sum types in Zig

An enum defines a set of named values: const Status = enum { placed, paid, shipped, cancelled };. Enums can have an explicit integer tag type (enum(u8)), explicit values, and methods. Enum literals are written with a leading dot (.paid), and @tagName returns the name as a string. A tagged union (union(enum)) holds one of several typed payloads plus a tag saying which, like Rust's enums or Haskell's sum types: const Payment = union(enum) { card: [4]u8, upi: []const u8, cash };. Use switch with payload captures (.upi => |vpa| ...) to handle each case; the switch must be exhaustive, so adding a new variant produces compile errors everywhere it is not handled. Accessing the wrong field of a tagged union panics in safe modes. Bare (untagged) unions and extern unions exist for low-level memory layout and C interop. Tagged unions are the idiomatic way to model state machines, messages, AST nodes and results with different shapes, making illegal states unrepresentable.

Payments and order states as tagged unions

Exhaustive switches with payload captures.

const std = @import("std");

const Payment = union(enum) {
    card: [4]u8,               // last four digits
    upi: []const u8,           // VPA such as asha@okbank
    cash,                      // no payload (void)
};

const OrderState = union(enum) {
    placed,
    paid: Payment,
    shipped: struct { payment: Payment, tracking: []const u8 },
    cancelled: []const u8,     // reason

    fn label(self: OrderState) []const u8 {
        return switch (self) {
            .placed => "placed",
            .paid => "paid",
            .shipped => "shipped",
            .cancelled => "cancelled",
        };
    }
};

fn describePayment(p: Payment) void {
    switch (p) {
        .card => |last4| std.debug.print("card ending {s}\n", .{&last4}),
        .upi => |vpa| std.debug.print("UPI {s}\n", .{vpa}),
        .cash => std.debug.print("cash on delivery\n", .{}),
    }
}

pub fn main() void {
    var state: OrderState = .placed;
    state = .{ .paid = .{ .upi = "asha@okbank" } };
    std.debug.print("state: {s}\n", .{state.label()});

    switch (state) {
        .paid => |payment| describePayment(payment),
        .shipped => |s| std.debug.print("tracking {s}\n", .{s.tracking}),
        .placed, .cancelled => {},
    }

    const Status = enum(u8) { placed = 1, paid, shipped };
    std.debug.print("{s} = {d}\n", .{ @tagName(Status.paid), @intFromEnum(Status.paid) });   // paid = 2
}

A parcel with a label

A tagged union is a parcel with a label on the outside saying what is inside: a card slip, a UPI note or nothing for cash. You must read the label (switch) before opening it, and the courier refuses parcels with labels nobody handles.

Quick check: What happens when you add a new variant to a tagged union that is switched on without an else prong?

  • Nothing
  • It is handled by the first prong
  • It panics at run time
  • Every non-exhaustive switch becomes a compile error until it handles the new case
Answer

Every non-exhaustive switch becomes a compile error until it handles the new case — Exhaustive switches make the compiler point to every place to update.