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.
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.
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.
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.
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.
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|.
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.
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.
Try It Yourself
Extend the shape calculator below to support a trapezoid variant. A trapezoid has two parallel sides (top and bottom) and a height. Its area formula is 0.5 * (top + bottom) * height. Add the variant to the union, implement the area case, and add a trapezoid to the shapes array.
const std = @import("std");
const Shape = union(enum) {
circle: f64,
rectangle: struct { width: f64, height: f64 },
// Add: trapezoid: struct { top: f64, bottom: f64, height: f64 },
fn area(self: Shape) f64 {
return switch (self) {
.circle => |r| std.math.pi * r * r,
.rectangle => |rect| rect.width * rect.height,
// Add trapezoid case here
};
}
};
pub fn main() void {
const shapes = [_]Shape{
.{ .circle = 3.0 },
.{ .rectangle = .{ .width = 5.0, .height = 4.0 } },
// Add: .{ .trapezoid = .{ .top = 3.0, .bottom = 7.0, .height = 4.0 } },
};
for (shapes) |shape| {
std.debug.print("{s}: area = {d:.2}\n", .{ @tagName(shape), shape.area() });
}
}
Key Takeaways
- Enums define a fixed set of named values and are exhaustively checked in
switchexpressions — missing a variant is a compile error - Enum backing types associate integer values with variants using
enum(u16)syntax @tagNameconverts any enum or tagged union value to its variant name as a string at runtime@intFromEnumand@enumFromIntconvert 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"or1,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 to work with that data efficiently you need to understand how it lives in memory. Next, you'll dive into pointers and references -- Zig's explicit model for passing data by address.
Next lesson
Pointers And References
Master Zig pointers — take addresses with &, dereference with .*, understand *T vs [*]T vs []T, and pass data by reference for efficient code.
28 min