Skip to content

Union Safety

A union stores one active field at a time.

A union stores one active field at a time.

That is the most important rule.

A struct groups fields together:

const Point = struct {
    x: i32,
    y: i32,
};

A Point has both x and y.

A union chooses one field:

const Value = union(enum) {
    int: i64,
    text: []const u8,
    flag: bool,
};

A Value is either an int, or a text, or a flag.

It is never all three at once.

The Active Field

When you create a tagged union value, you choose the active field:

const value = Value{ .text = "hello" };

Here, the active field is text.

The payload is "hello".

This means the value is not an integer and not a boolean. It is the text case.

The safe way to read it is with switch:

switch (value) {
    .int => |n| {
        std.debug.print("int = {}\n", .{n});
    },
    .text => |s| {
        std.debug.print("text = {s}\n", .{s});
    },
    .flag => |b| {
        std.debug.print("flag = {}\n", .{b});
    },
}

Inside each branch, Zig gives you the payload for that branch.

That is safe because the branch tells Zig which field is active.

Why Direct Field Access Can Be Dangerous

A union value only has one active field.

So this thinking is wrong:

value.int exists
value.text exists
value.flag exists

Only one exists at a time.

If the value was created like this:

const value = Value{ .text = "hello" };

then the active field is text.

Reading value.text matches the active field.

Reading value.int does not.

Tagged unions are designed to prevent this kind of mistake. The usual pattern is to use switch, because switch checks the active tag before giving you the payload.

Tagged Unions Are the Safe Default

In Zig, prefer this form:

const Value = union(enum) {
    int: i64,
    text: []const u8,
    flag: bool,
};

The enum tag records which field is active.

That tag gives Zig enough information to check your code.

A tagged union is safe because the value carries its case with it.

You can ask, “Which case is active?” and then handle it.

This is why union(enum) is the normal beginner-friendly form.

Bare Unions

Zig also has bare unions:

const Value = union {
    int: i64,
    text: []const u8,
    flag: bool,
};

This does not store an automatic tag.

The program must know which field is active by some other means.

That is more dangerous.

With a bare union, the data exists, but the union itself does not remember which field is valid. This can be useful for low-level programming, but it is not the right default.

For ordinary application code, use a tagged union.

The Difference

Compare these two definitions:

const SafeValue = union(enum) {
    int: i64,
    text: []const u8,
    flag: bool,
};

const RawValue = union {
    int: i64,
    text: []const u8,
    flag: bool,
};

SafeValue stores a tag.

RawValue does not.

That one difference changes how you should use them.

TypeHas active tag?Best use
union(enum)YesNormal program modeling
unionNoLow-level representation work

A bare union is closer to raw memory control.

A tagged union is closer to safe data modeling.

Switching Is the Main Safety Tool

Use switch when handling a tagged union.

fn printValue(value: Value) void {
    switch (value) {
        .int => |n| {
            std.debug.print("int = {}\n", .{n});
        },
        .text => |s| {
            std.debug.print("text = {s}\n", .{s});
        },
        .flag => |b| {
            std.debug.print("flag = {}\n", .{b});
        },
    }
}

This has two benefits.

First, the code only reads the payload after checking the active case.

Second, the compiler can check whether all cases are handled.

If you later add a new case:

const Value = union(enum) {
    int: i64,
    text: []const u8,
    flag: bool,
    none,
};

then switches that do not handle none can fail to compile.

That is safety. The compiler forces your logic to stay aligned with your data model.

Capturing Payloads

A union case with data has a payload.

.text => |s| {
    std.debug.print("{s}\n", .{s});
}

The variable s only exists inside that branch.

Its type is known from the union definition:

text: []const u8

So in the branch:

.text => |s|

s has type:

[]const u8

For this case:

.int => |n|

n has type:

i64

The switch branch narrows the value to the correct payload type.

Capturing Mutable Payloads

Sometimes you need to modify the payload inside a union.

Use a pointer capture:

const Value = union(enum) {
    count: i32,
    text: []const u8,
};

fn increment(value: *Value) void {
    switch (value.*) {
        .count => |*n| {
            n.* += 1;
        },
        .text => {},
    }
}

Read this carefully.

The function receives:

value: *Value

That means it receives a pointer to a Value.

The switch uses:

switch (value.*)

That means it switches on the actual value behind the pointer.

The branch uses:

.count => |*n|

That captures a pointer to the count payload.

Then:

n.* += 1;

modifies the payload.

This is more advanced, but the principle is the same: only touch the payload in the branch where that payload is active.

Replacing the Active Field

You can replace a union value with another case:

var value = Value{ .count = 10 };

value = Value{ .text = "done" };

After the assignment, the active field changed.

Before:

active field = count
payload = 10

After:

active field = text
payload = "done"

You replaced the whole value.

This is safe and common.

Do Not Keep Old Payload Pointers After Changing the Tag

Be careful with pointers to union payloads.

Suppose you capture a pointer to a payload, then change the union to another case. That pointer no longer refers to a valid active payload.

Conceptually:

var value = Value{ .count = 10 };

// pointer to count payload
// then value changes to text
// old pointer should not be used

The rule is simple: a payload pointer is only meaningful while that case remains active.

Do not store it and use it after changing the union.

Optional Values vs Tagged Unions

An optional is a small special case.

?i32

means:

either no value
or an i32 value

A tagged union can express the same idea:

const MaybeInt = union(enum) {
    none,
    some: i32,
};

But Zig already gives you optionals for this common pattern.

Use ?T when there are only two cases:

missing
present

Use a tagged union when there are several meaningful cases:

const ParseResult = union(enum) {
    ok: i64,
    empty,
    invalid_digit: u8,
    overflow,
};

This carries more information than a simple optional.

Error Unions vs Tagged Unions

An error union is another special case.

!i32

means:

either an error
or an i32 value

Use error unions when the operation either succeeds or fails.

Use tagged unions when the result has several normal shapes.

For example:

fn readNumber() !i32 {
    // success gives i32
    // failure gives an error
}

But for a parser that wants to explain different non-error outcomes:

const Token = union(enum) {
    number: i64,
    identifier: []const u8,
    symbol: u8,
    end_of_file,
};

A token is not “success or failure.” It is one of several valid cases.

Invalid States

The main safety benefit of tagged unions is that they prevent invalid combinations.

This struct can represent bad states:

const Response = struct {
    status: Status,
    body: ?[]const u8,
    redirect_url: ?[]const u8,
};

It might contain both a body and a redirect URL.

It might contain neither.

It might say the status is redirect but have no redirect URL.

A tagged union can encode the rules directly:

const Response = union(enum) {
    ok: []const u8,
    redirect: []const u8,
    not_found,
};

Now each case carries exactly what it needs.

ok carries a body.

redirect carries a URL.

not_found carries no payload.

There is no field combination to accidentally misuse.

Safety Through Shape

Good type design makes invalid states hard to write.

This is weak:

const Job = struct {
    kind: JobKind,
    path: ?[]const u8,
    command: ?[]const u8,
};

This is stronger:

const Job = union(enum) {
    read_file: []const u8,
    run_command: []const u8,
    stop,
};

The tagged union says exactly what each job is.

A read_file job has a path.

A run_command job has a command.

A stop job has no payload.

The type itself now carries the rule.

A Complete Example

const std = @import("std");

const Event = union(enum) {
    connected,
    disconnected,
    message: []const u8,
    error_code: u32,

    fn print(self: Event) void {
        switch (self) {
            .connected => {
                std.debug.print("connected\n", .{});
            },
            .disconnected => {
                std.debug.print("disconnected\n", .{});
            },
            .message => |text| {
                std.debug.print("message: {s}\n", .{text});
            },
            .error_code => |code| {
                std.debug.print("error code: {}\n", .{code});
            },
        }
    }
};

pub fn main() void {
    const events = [_]Event{
        .connected,
        Event{ .message = "hello" },
        Event{ .error_code = 500 },
        .disconnected,
    };

    for (events) |event| {
        event.print();
    }
}

Output:

connected
message: hello
error code: 500
disconnected

The array contains different kinds of events, but every item has the same type: Event.

Each event carries only the data that matches its case.

The Main Idea

A union is safe when you always respect its active field.

For beginner Zig code, use tagged unions:

const Value = union(enum) {
    int: i64,
    text: []const u8,
    flag: bool,
};

Then handle them with switch:

switch (value) {
    .int => |n| {},
    .text => |s| {},
    .flag => |b| {},
}

A tagged union stores both the case and the payload. That lets Zig check your code and helps you model data without invalid field combinations.