A tagged union is a type that can store one value from several possible shapes.
Use a tagged union when a value can be different kinds of things, and each kind may carry different data.
For example, imagine a command-line program that accepts several commands:
const Command = union(enum) {
quit,
help,
open: []const u8,
write: []const u8,
};This says a Command can be one of four cases:
quit
help
open, with a file path
write, with some textThe cases quit and help carry no extra data.
The case open carries a string.
The case write carries a string.
That is the core idea of a tagged union: one value, several possible cases, and each case may have its own data.
Why Not Just Use a Struct?
Suppose we tried to model this with a struct:
const Command = struct {
kind: CommandKind,
path: ?[]const u8,
text: ?[]const u8,
};This can work, but it allows invalid states.
For example:
const command = Command{
.kind = .quit,
.path = "data.txt",
.text = "hello",
};What does that mean?
A quit command should not have a path or text. But the struct allows it.
A tagged union avoids this. It stores exactly one active case.
const command = Command.quit;or:
const command = Command{ .open = "data.txt" };The shape of the value matches the case.
Defining a Tagged Union
The syntax is:
const Name = union(enum) {
case_name,
case_name: PayloadType,
};Example:
const Message = union(enum) {
ping,
text: []const u8,
number: i32,
};A Message can be:
const a = Message.ping;
const b = Message{ .text = "hello" };
const c = Message{ .number = 42 };Only one case is active at a time.
Switching on a Tagged Union
Tagged unions work best with switch.
const std = @import("std");
const Message = union(enum) {
ping,
text: []const u8,
number: i32,
};
pub fn main() void {
const msg = Message{ .text = "hello" };
switch (msg) {
.ping => {
std.debug.print("ping\n", .{});
},
.text => |value| {
std.debug.print("text: {s}\n", .{value});
},
.number => |value| {
std.debug.print("number: {}\n", .{value});
},
}
}Output:
text: helloThis part is important:
.text => |value| {
std.debug.print("text: {s}\n", .{value});
}The |value| captures the payload stored in the text case.
For .ping, there is no payload, so there is nothing to capture.
Tags and Payloads
A tagged union has two ideas:
The tag tells you which case is active.
The payload is the data stored by that case.
In this example:
const Message = union(enum) {
ping,
text: []const u8,
number: i32,
};The possible tags are:
ping
text
numberThe payloads are:
ping has no payload
text has []const u8
number has i32When the value is:
const msg = Message{ .number = 42 };The active tag is number.
The payload is 42.
Accessing the Active Payload
You normally access the payload through switch.
switch (msg) {
.text => |value| {
// value is the text payload
},
else => {},
}This is safe because Zig only gives you the payload in the branch where that case is active.
Do not think of a union as a struct where all fields exist. Only one field is active.
Tagged Unions and Exhaustive Handling
Like enums, tagged unions help Zig check your logic.
If you switch on a tagged union, you should handle all cases:
switch (msg) {
.ping => {},
.text => |value| {
_ = value;
},
.number => |value| {
_ = value;
},
}If you later add another case, Zig can force you to update the switch.
That is one of the main reasons tagged unions are useful. They make changing data models safer.
Tagged Unions as Results
Tagged unions are useful when a result can have different successful shapes.
Example:
const LookupResult = union(enum) {
not_found,
user: User,
redirect: []const u8,
};
const User = struct {
id: u64,
name: []const u8,
};A lookup may produce:
not_found
a user
a redirect pathEach case is clear.
fn handle(result: LookupResult) void {
switch (result) {
.not_found => {
// show 404
},
.user => |user| {
_ = user;
// show user page
},
.redirect => |path| {
_ = path;
// redirect to path
},
}
}A plain struct would need optional fields and extra rules. The tagged union puts the rules into the type.
Tagged Unions as AST Nodes
Tagged unions are especially useful for parsers and interpreters.
For example, a small expression language might have these expression kinds:
const Expr = union(enum) {
integer: i64,
add: Binary,
multiply: Binary,
const Binary = struct {
left: *Expr,
right: *Expr,
};
};An expression can be:
an integer
an addition expression
a multiplication expressionEach kind carries the data it needs.
The integer case carries an i64.
The addition and multiplication cases carry two child expressions.
This is a common pattern in compilers, interpreters, and data format parsers.
Methods on Tagged Unions
A tagged union can contain methods, just like structs and enums.
const std = @import("std");
const Message = union(enum) {
ping,
text: []const u8,
number: i32,
fn print(self: Message) void {
switch (self) {
.ping => {
std.debug.print("ping\n", .{});
},
.text => |value| {
std.debug.print("text: {s}\n", .{value});
},
.number => |value| {
std.debug.print("number: {}\n", .{value});
},
}
}
};
pub fn main() void {
const msg = Message{ .number = 42 };
msg.print();
}Output:
number: 42The method receives self: Message, then switches on it.
This keeps behavior close to the type.
Changing a Tagged Union Value
If a tagged union value is mutable, you can replace it with another case.
var msg = Message.ping;
msg = Message{ .text = "hello" };
msg = Message{ .number = 123 };At each moment, only one case is active.
First, it is ping.
Then it is text.
Then it is number.
You are not changing several fields. You are replacing the whole active value.
Anonymous Tag Type
When you write:
const Message = union(enum) {
ping,
text: []const u8,
number: i32,
};Zig creates an enum tag type for you.
That is why this is called a tagged union. The union has a tag, and the tag records which field is active.
This is the common form. Beginners should start with union(enum).
There are also bare unions and unions with explicit tag enums, but those are more advanced. For ordinary safe modeling, use tagged unions.
Common Mistake: Thinking All Fields Exist
This is wrong thinking:
A Message has ping, text, and number fields.A better model is:
A Message is either ping, or text, or number.Only one case is active.
That is why this works:
const msg = Message{ .text = "hello" };And why this does not make sense:
// not a real Message shape
.text = "hello"
.number = 42A tagged union is for alternatives, not for grouping fields together.
Use a struct for grouping.
Use a tagged union for choosing one case from several.
Common Mistake: Using Optional Fields Instead
You may be tempted to write this:
const Value = struct {
int_value: ?i64 = null,
string_value: ?[]const u8 = null,
bool_value: ?bool = null,
};This allows many bad states:
const value = Value{
.int_value = 10,
.string_value = "hello",
.bool_value = true,
};What is this value supposed to be?
An integer? A string? A boolean?
A tagged union is clearer:
const Value = union(enum) {
int: i64,
string: []const u8,
boolean: bool,
};Now every value has exactly one kind.
const value = Value{ .string = "hello" };This is precise.
Tagged Union vs Enum
An enum gives you a fixed list of choices, but no extra data per choice.
const Status = enum {
pending,
active,
failed,
};A tagged union gives you a fixed list of choices, and each choice may carry data.
const Event = union(enum) {
connected,
disconnected,
message: []const u8,
error_code: u32,
};Use an enum when names are enough.
Use a tagged union when some cases need data.
A Practical Example: Command Parser Result
const std = @import("std");
const Command = union(enum) {
quit,
help,
open: []const u8,
write: []const u8,
fn print(self: Command) void {
switch (self) {
.quit => {
std.debug.print("quit\n", .{});
},
.help => {
std.debug.print("help\n", .{});
},
.open => |path| {
std.debug.print("open: {s}\n", .{path});
},
.write => |text| {
std.debug.print("write: {s}\n", .{text});
},
}
}
};
pub fn main() void {
const commands = [_]Command{
.help,
Command{ .open = "notes.txt" },
Command{ .write = "hello" },
.quit,
};
for (commands) |command| {
command.print();
}
}Output:
help
open: notes.txt
write: hello
quitThis example shows why tagged unions are useful. Each command has exactly the data it needs.
help needs no data.
open needs a path.
write needs text.
quit needs no data.
The type itself describes those rules.
The Main Idea
A tagged union represents one choice from several possible cases.
const Value = union(enum) {
int: i64,
string: []const u8,
boolean: bool,
};This value is one of those cases:
const value = Value{ .string = "hello" };Use tagged unions when your data has alternatives. They are one of Zig’s best tools for representing states, messages, commands, parser nodes, protocol events, and results with different shapes.