Skip to content
Make room for learningYour learning studio
Course outline

Enums And Tagged Unions

Enums and tagged unions are two of Zig's most expressive type constructs. Together, they give you a type-safe way to represent values that can be one of several distinct possibilities — a fundamental need in almost every real-world program. Instead of relying on magic numbers or string constants to represent state, these types let the compiler verify your logic is complete.

What You Already Know

The last lesson left you with a promise and a partial picture. In Optionals And Unions you wrote ?T for a value that might be absent, unwrapped it with if (opt) |value|, and then met a union(enum) for the first time — a Reading that was ok, out_of_range, or disconnected — under a heading that said tagged unions get their full treatment later. This is later, and the thing that carries over is not the syntax but the observation the lesson ended on: the |value| that unwrapped your optional and the |value| that pulled a payload out of that switch are the same capture doing the same job. An optional is the two-variant case of what this lesson generalises. ?T is something or nothing; a tagged union is one of these several things, and each of them may carry data of its own.

So you already know how to read a payload out of a variant. What you do not yet have is the half that makes it safe at scale: naming the variants, making the compiler prove you handled every one of them, and turning a tag back into something printable. Over this lesson you will define an enum for a fixed set of choices, switch over it exhaustively with no else prong to hide a missing case, attach a different payload type to each variant of a union, and use @tagName to label them. If the |capture| syntax feels shaky, the Optionals And Unions material is the place to firm it up — everything here is built on it.

That is what the record analyzer's report formatter needs. Its render function takes an OutputFormat and switches on it to emit a table, CSV, or JSON, and the reason a fourth format cannot be quietly forgotten is the exhaustiveness you are about to make the compiler enforce.

Enums: Named Choices

An enum (short for enumeration) defines a fixed set of named values. When you model something that has a known set of possibilities, an enum is usually the right tool.

zig
const std = @import("std");

const Direction = enum {
    north,
    south,
    east,
    west,
};

pub fn main() void {
    const dir = Direction.north;

    const opposite = switch (dir) {
        .north => Direction.south,
        .south => Direction.north,
        .east => Direction.west,
        .west => Direction.east,
    };

    std.debug.print("Facing: {s}\n", .{@tagName(dir)});
    std.debug.print("Opposite: {s}\n", .{@tagName(opposite)});
}

Notice several things here. Enum values use dot syntax (.north) when the type is inferred from context. The switch on an enum must be exhaustive — the compiler rejects any switch that doesn't handle every variant. And @tagName is a built-in that converts an enum value to its string name at runtime.

Enums with Integer Values and Methods

Zig enums can carry an explicit integer backing type and define methods, making them significantly more capable than enums in many other languages.

zig
const std = @import("std");

const HttpStatus = enum(u16) {
    ok = 200,
    not_found = 404,
    internal_error = 500,
    unauthorized = 401,

    fn isSuccess(self: HttpStatus) bool {
        const code = @intFromEnum(self);
        return code >= 200 and code < 300;
    }

    fn message(self: HttpStatus) []const u8 {
        return switch (self) {
            .ok => "OK",
            .not_found => "Not Found",
            .internal_error => "Internal Server Error",
            .unauthorized => "Unauthorized",
        };
    }
};

pub fn main() void {
    const statuses = [_]HttpStatus{ .ok, .not_found, .internal_error, .unauthorized };

    for (statuses) |status| {
        std.debug.print("HTTP {d}: {s} (success={})\n", .{
            @intFromEnum(status),
            status.message(),
            status.isSuccess(),
        });
    }
}

The (u16) after enum sets the backing integer type. You convert between an enum and its underlying integer with @intFromEnum and @enumFromInt. Methods work exactly like struct methods — they receive the enum value as self.

An explicit backing value and a variant's position in the declaration are two different things. Work out both printed numbers below before you run it.

Predict

Each variant of this enum has an explicit value, and the declaration order does not match it. What do the two lines print?

zig
const Level = enum(u8) {
  debug = 10,
  info = 20,
  warn = 30,
};

std.debug.print("{d}\n", .{@intFromEnum(Level.info)});
std.debug.print("{d}\n", .{@intFromEnum(Level.warn)});

The lines print 20 and 30. Once a variant carries an explicit value, @intFromEnum returns that value, not the variant's position — which is exactly why HttpStatus above can hold 200, 404, 500, and 401 in any order. Leave the values off and Zig numbers the variants 0, 1, 2, … in declaration order instead.

Tagged Unions: One Type, Many Shapes

A tagged union holds one of several possible types at a time and always knows which type it currently holds. This is sometimes called a "sum type" or "variant" in other languages. It is the combination of a union (multiple possible data layouts) with an enum tag that tracks the active variant.

zig
const std = @import("std");

const Shape = union(enum) {
    circle: f64,
    rectangle: struct { width: f64, height: f64 },
    triangle: struct { base: f64, height: f64 },

    fn area(self: Shape) f64 {
        return switch (self) {
            .circle => |r| std.math.pi * r * r,
            .rectangle => |rect| rect.width * rect.height,
            .triangle => |tri| 0.5 * tri.base * tri.height,
        };
    }

    fn describe(self: Shape) void {
        switch (self) {
            .circle => |r| std.debug.print("Circle  radius={d:.1}\n", .{r}),
            .rectangle => |rect| std.debug.print("Rect    {d:.1} x {d:.1}\n", .{ rect.width, rect.height }),
            .triangle => |tri| std.debug.print("Tri     base={d:.1} height={d:.1}\n", .{ tri.base, tri.height }),
        }
    }
};

pub fn main() void {
    const shapes = [_]Shape{
        .{ .circle = 5.0 },
        .{ .rectangle = .{ .width = 4.0, .height = 6.0 } },
        .{ .triangle = .{ .base = 3.0, .height = 8.0 } },
    };

    for (shapes) |shape| {
        shape.describe();
        std.debug.print("  area = {d:.2}\n\n", .{shape.area()});
    }
}

The union(enum) syntax creates a tagged union with an auto-generated enum tag. Each field holds a different payload type. When you switch on a tagged union, you capture the payload with |variable| syntax — similar to how you unwrap optional values with if (opt) |val|.

That capture is not decoration. The next program skips it and reads a union field directly, which is where tagged unions bite. Commit a hypothesis before you touch it.

Debug

This program should print the perimeter of every shape in the list. It compiles, prints the first line, and then dies. Say why before you change anything, then fix it so both lines print.

zig
const std = @import("std");

const Shape = union(enum) {
  square: f64,
  rect: struct { width: f64, height: f64 },
};

fn perimeter(shape: Shape) f64 {
  // Read the payload straight off the union, no switch involved.
  return 4.0 * shape.square;
}

pub fn main() void {
  const shapes = [_]Shape{
      .{ .square = 3.0 },
      .{ .rect = .{ .width = 2.0, .height = 5.0 } },
  };

  for (shapes) |shape| {
      std.debug.print("{s}: perimeter = {d:.1}\n", .{ @tagName(shape), perimeter(shape) });
  }
}

Expected output:

square: perimeter = 12.0
rect: perimeter = 14.0

The bug is direct field access. A tagged union knows which variant is active, and reading a different field is caught at runtime — so the .square read survives the first shape and panics on the second with access of union field 'square' while field 'rect' is active. Replacing the body with return switch (shape) { .square => |side| 4.0 * side, .rect => |r| 2.0 * (r.width + r.height) }; fixes it: each prong runs only for its own variant, and the |capture| hands you that variant's payload at the right type.

Now assemble one yourself. The lines below are the body of a function that swaps a new value into a slot and hands back whatever the slot held before — the pieces are shuffled, and only one arrangement answers the caller honestly.

Arrange the code

These lines form a function that stores a new value in a slot and returns what the slot held before. The slot is a tagged union that is either empty or filled with a number. Reorder them so the caller sees first=0 then second=10 — then say which single line, moved one position, still compiles but makes the function answer with the value it was just handed instead of the one it replaced.

  1. return previous;
  2. const previous = switch (slot.*) { .empty => 0, .filled => |v| v };
  3. slot.* = .{ .filled = next };
  4. }
  5. fn replace(slot: *Slot, next: i32) i32 {

Tagged unions are one of the places where Zig lines up closely with another language, and it is worth being precise about where the correspondence stops.

Transfer

A Zig union(enum) gives each variant its own payload type and makes a switch prove every variant is handled. Rust's enum does the same job with match. Both compile the tag check down to a jump on a small integer. Which statement names the mechanism the two genuinely share, rather than a resemblance that does not hold up?

Modeling Real-World State

Tagged unions are particularly useful when different program states carry different data. A common example is a configuration system where each entry can hold one of several value types.

zig
const std = @import("std");

const Value = union(enum) {
    boolean: bool,
    integer: i64,
    float: f64,
    text: []const u8,

    fn display(self: Value) void {
        switch (self) {
            .boolean => |b| std.debug.print("{}", .{b}),
            .integer => |i| std.debug.print("{d}", .{i}),
            .float => |f| std.debug.print("{d:.3}", .{f}),
            .text => |t| std.debug.print("\"{s}\"", .{t}),
        }
    }
};

const ConfigEntry = struct {
    key: []const u8,
    value: Value,
};

pub fn main() void {
    const config = [_]ConfigEntry{
        .{ .key = "debug",   .value = .{ .boolean = true } },
        .{ .key = "port",    .value = .{ .integer = 8080 } },
        .{ .key = "timeout", .value = .{ .float = 30.5 } },
        .{ .key = "host",    .value = .{ .text = "localhost" } },
    };

    std.debug.print("Configuration:\n", .{});
    for (config) |entry| {
        std.debug.print("  {s} ({s}) = ", .{ entry.key, @tagName(entry.value) });
        entry.value.display();
        std.debug.print("\n", .{});
    }
}

Note that @tagName works on tagged union values just as it does on plain enums — it returns the name of the currently active variant as a string.

You have now met three different ways to bundle data into one type. Close the code and see whether you can separate them from memory.

Recall

Without scrolling up: in Arrays and Structs you built a struct with several fields, and this lesson's ConfigEntry holds a tagged union. If a struct has four fields and a tagged union has four variants, how many of those four does each type hold at once?

A struct is an AND: every field exists at once, so a record has a name and a category and a value. A tagged union is an OR: exactly one variant is live, the variants share storage, and the tag records which one — which is why you reach the payload through a switch and a |capture| rather than by naming a field. An optional (?i32) is the smallest OR of all: a value, or nothing.

Try It Yourself

Reading about tagged unions is not the same as reaching for one by intent. This is a build task: a small program that reports its own pass/fail. Three functions are stubbed out — one wants an enum method, one wants an exhaustive union switch with payload capture, one wants @tagName. Run it as-is and it fails immediately, naming the first check that did not pass. Implement each one until every check passes and it prints All checks passed.

There is no assert to import: the starter defines its own check helper, which prints the failure and returns an error. Because an error returned from main ends the program with a non-zero exit status, the first failing check stops everything.

Build

Finish the build. Three functions are stubbed out and the checks below them fail until each one returns the right value. Run it as-is to see which check fails first, decide what that function needs, then implement all three until it prints 'All checks passed.' The checks run top to bottom, so the first failure you see is TODO 1 — implement it first, then work down.

zig
const std = @import("std");

const Priority = enum(u8) {
  low = 1,
  normal = 5,
  urgent = 9,

  // TODO 1: return the variant's backing integer as a u8.
  //   Priority.low.weight() -> 1, Priority.urgent.weight() -> 9
  fn weight(self: Priority) u8 {
      _ = self;
      return 0;
  }
};

const Shape = union(enum) {
  circle: f64,
  rect: struct { width: f64, height: f64 },
  trapezoid: struct { top: f64, bottom: f64, height: f64 },
};

// TODO 2: return the area of any Shape with an EXHAUSTIVE switch that captures
//   each payload. No else prong.
//   circle r -> 3.14159... * r * r   (use std.math.pi)
//   rect     -> width * height
//   trapezoid -> 0.5 * (top + bottom) * height
fn area(shape: Shape) f64 {
  _ = shape;
  return 0;
}

// TODO 3: return the name of the ACTIVE variant of a Shape as a string.
//   One builtin does this in one line — no switch needed.
//   kindOf(.{ .circle = 1.0 }) -> "circle"
fn kindOf(shape: Shape) []const u8 {
  _ = shape;
  return "";
}

// A tiny stand-in for an assert: print what went wrong and return an error.
fn check(ok: bool, comptime message: []const u8) !void {
  if (!ok) {
      std.debug.print("CHECK FAILED: {s}\n", .{message});
      return error.CheckFailed;
  }
}

// --- Build checks: Do not edit below this line. ---
pub fn main() !void {
  try check(Priority.low.weight() == 1, "Priority.low.weight() should be 1, its backing value");
  try check(Priority.normal.weight() == 5, "Priority.normal.weight() should be 5, not its position 1");
  try check(Priority.urgent.weight() == 9, "Priority.urgent.weight() should be 9");

  const c = Shape{ .circle = 2.0 };
  const r = Shape{ .rect = .{ .width = 3.0, .height = 4.0 } };
  const t = Shape{ .trapezoid = .{ .top = 3.0, .bottom = 7.0, .height = 4.0 } };

  try check(@abs(area(c) - 12.566370614359172) < 0.0001, "area of a circle with radius 2 should be pi * 4");
  try check(@abs(area(r) - 12.0) < 0.0001, "area of a 3 x 4 rect should be 12");
  try check(@abs(area(t) - 20.0) < 0.0001, "area of a trapezoid (top 3, bottom 7, height 4) should be 20");

  try check(std.mem.eql(u8, kindOf(c), "circle"), "kindOf a circle should be \"circle\"");
  try check(std.mem.eql(u8, kindOf(r), "rect"), "kindOf a rect should be \"rect\"");
  try check(std.mem.eql(u8, kindOf(t), "trapezoid"), "kindOf a trapezoid should be \"trapezoid\"");

  std.debug.print("All checks passed.\n", .{});
  std.debug.print("Weights: {} {} {}\n", .{ Priority.low.weight(), Priority.normal.weight(), Priority.urgent.weight() });
  std.debug.print("{s} area = {d:.2}\n", .{ kindOf(t), area(t) });
}

Expected output:

All checks passed.
Weights: 1 5 9
trapezoid area = 20.00

Once it passes, try two variations and predict each before running:

  1. Delete a switch prong. In area, remove the .trapezoid prong entirely. Predict whether this is a runtime failure or a build failure before running. It never runs: a switch over a tagged union with no else must handle every variant, so the compiler stops with error: switch must handle all possibilities. Adding a variant to a union forces every switch over it to be updated — that is the guarantee you are buying.
  2. Return the position instead of the value. In weight, replace @intFromEnum(self) with a hardcoded return 1;. Predict which check fails first. The Priority.low check still passes (its value really is 1), and the second check fails with Priority.normal.weight() should be 5, not its position 1 — a good reminder that a check passing tells you nothing about the checks after it.

Capstone milestone

Milestone — report formatting. The capstone's Record Analyzer renders its report in one of several formats, chosen by an OutputFormat enum and dispatched with an exhaustive switch. The shape report you just built is that dispatch in miniature: an enum naming the choices, and a switch that handles every one of them. Confirm you can model a fixed set of choices and dispatch on it.

Hint: You don't need the full report yet — this confirms the enum-and-switch dispatch the formatter is built on. In the capstone, render takes an OutputFormat and switches on it to emit a table, CSV, or JSON, and the compiler makes sure a fourth format cannot be added without updating that switch.

  • Defined an enum for a fixed set of named choices instead of using strings or magic numbers
  • Wrote an exhaustive switch over that enum with no else prong
  • Captured a tagged union's payload with |name| inside a switch prong rather than reading a field directly
  • Used @tagName to turn an enum or union value into a printable label

Key Takeaways

  • Enums define a fixed set of named values and are exhaustively checked in switch expressions — missing a variant is a compile error
  • Enum backing types associate integer values with variants using enum(u16) syntax
  • @tagName converts any enum or tagged union value to its variant name as a string at runtime
  • @intFromEnum and @enumFromInt convert between enum values and their underlying integers
  • Tagged unions (union(enum)) hold exactly one of several possible types and always track which variant is active
  • Payload capture in switch uses |variable| syntax to access the data stored in the active variant
  • Both enums and tagged unions support methods, making them powerful tools for domain modeling
  • The compiler enforces exhaustive matching — adding a new variant forces you to update every switch in your codebase

Pro Tip: When you find yourself using strings or integers to represent state (like "circle" or 1, 2, 3), that is a signal to reach for an enum. When different states must carry different data, upgrade to a tagged union. The exhaustive switch checking becomes a safety net: the next time you add a variant, the compiler highlights every location that needs to handle it, making refactors far more reliable.

Next Steps

Enums and tagged unions give you rich data modeling, but you also need to pass collections of that data around efficiently. Next, you'll learn how slices give you flexible, zero-cost views into arrays and how to iterate over them.

Next lesson

Slices and Iteration

Master Zig slices and iteration — use fat pointers for safe array views, iterate with for loops, and leverage std.mem utilities for searching and splitting.

22 min