zig-enums-unions

Enums and Tagged Unions in Zig

  • 5 min

An enum in Zig is a type that represents one option within a closed set of values. So far, our structs have been rigid, but in the real world we often need to model states or mutually exclusive data.

A web operation result can be Success (with JSON data) Or an Error (with an HTTP code). Never both at the same time.

In Zig we can model this using enums and tagged unions, without resorting to a class hierarchy.

Enums: More Than Numbers

An enum (enumeration) defines a closed set of possible values. At the machine level, they are usually simple integers, but Zig allows us to treat them with much richer semantics.

const State = enum {
    idle,
    loading,
    ready,
    failed,
};

const current_state = State.loading;
Copied!

Underlying Types

By default, Zig decides which integer to use to represent each case. But if we need to interact with hardware or binary protocols, we can choose the exact type (u8, u32, etc.) and the values.

// Force it to be a u8 and define specific values
const HTTPCode = enum(u16) {
    ok = 200,
    not_found = 404,
    server_error = 500,
};
Copied!

Enums with Methods

Here Zig separates itself from C. Since an enum is also a container, it can have functions and methods.

This allows us to keep related logic together with the type. Instead of having a function isEndState(state), we can define a method on the state itself.

const GameState = enum {
    menu,
    playing,
    game_over,

    // Method that receives 'self'
    pub fn canPause(self: GameState) bool {
        // 'self' is the current enum value
        return self == .playing;
    }
    
    // Transition function
    pub fn next(self: GameState) GameState {
        return switch (self) {
            .menu => .playing,
            .playing => .game_over,
            .game_over => .menu,
        };
    }
};

pub fn main() void {
    const st = GameState.playing;
    if (st.canPause()) {
        std.debug.print("Pause allowed\n", .{});
    }
}
Copied!

This feature allows grouping related data and behavior without introducing classes.

Bare Unions

A union is a data type that can store one of several possible types, but only one at a time. All fields share the same memory address.

The union reserves enough space for its largest field, also respecting alignment requirements.

const RawNumber = union {
    integer: i32,
    float: f64,
};

var n = RawNumber{ .integer = 10 };
// In memory, there is a 10.
// When assigning another value to the union, we overwrite those bits.
n = RawNumber{ .float = 3.14 };
Copied!

Bare unions are useful for interoperability with C and low-level code, but they do not record which field is active. Accessing a different field than the last one written is illegal behavior: safe modes can detect it, while ReleaseFast and ReleaseSmall are not required to do so.

Tagged Unions

To associate each variant with the active field, Zig offers tagged unions.

We combine an enum (the tag) with a union (the data or payload). The tag is part of the value and allows us to know which field is active.

The syntax is union(EnumTag). If we don’t want to create the enum explicitly, we use union(enum).

// Define a Tagged Union
const Result = union(enum) {
    success: u32,            // If it's success, it carries a number
    network_error: u16,      // If it's a network error, it carries a code
    unknown: void,           // If it's unknown, it carries no data
};
Copied!

Now we have a safe polymorphic type. A Result variable can be a number, an error, or nothing.

Handling with switch

The usual way to access the data of a tagged union is via switch. This way we handle all cases and can capture the payload of the active branch.

const res = Result{ .success = 100 };

switch (res) {
    // Capture the value with |value|
    .success => |value| std.debug.print("You won {d} points\n", .{value}),
    
    // Capture the error code
    .network_error => |code| std.debug.print("HTTP Error {d}\n", .{code}),
    
    // Case without data (void)
    .unknown => std.debug.print("No idea what happened\n", .{}),
}
Copied!

Unsafe access attempt: If you access res.success when the active variant is .network_error, you incur illegal behavior. Safe modes detect this with a panic; an exhaustive switch prevents that incorrect access.

Unions and Polymorphism

In OOP languages, if you want a list of different objects (a Circle and a Square), you use a base class Shape and inheritance.

In Zig, we use a Tagged Union.

const Circle = struct { radius: f32 };
const Square = struct { side: f32 };

const Shape = union(enum) {
    circle: Circle,
    square: Square,
    
    pub fn area(self: Shape) f32 {
        return switch (self) {
            .circle => |c| 3.14 * c.radius * c.radius,
            .square => |q| q.side * q.side,
        };
    }
};
Copied!

This design represents a closed set of variants without virtual tables or mandatory dynamic allocations. The size of Shape includes the tag, the space for the largest variant, and the corresponding alignment padding.