Tagged Unions and switch in Zig
A tagged union holds one of several different types and remembers which one. Zig writes this as union(enum), and it pairs with switch to give you what C unions cannot: the compiler checks that you handled every case.
const std = @import("std");
const Shape = union(enum) {
circle: f64,
rectangle: struct { w: f64, h: f64 },
point,
fn area(self: Shape) f64 {
return switch (self) {
.circle => |r| std.math.pi * r * r,
.rectangle => |rect| rect.w * rect.h,
.point => 0,
};
}
};
pub fn main(init: std.process.Init) !void {
_ = init;
const shapes = [_]Shape{
.{ .circle = 2.0 },
.{ .rectangle = .{ .w = 3.0, .h = 4.0 } },
.point,
};
for (shapes) |shape| {
std.debug.print("{s:<10} area = {d:.3}\n", .{ @tagName(shape), shape.area() });
}
var counter = Shape{ .circle = 1.0 };
switch (counter) {
.circle => |*r| r.* += 9.0,
else => {},
}
std.debug.print("after mutation: {d:.3}\n", .{counter.area()});
}
The program declares Shape as a union(enum). The (enum) part tells Zig to generate an enum tag automatically, with one value per field. Each field carries its own payload: circle a single f64 radius, rectangle an anonymous struct with two fields, and point nothing at all. A field with no payload is just a tag and costs nothing. Like a struct, a union can contain functions, so area() is called as shape.area().
The body of area() is a switch used as an expression — it returns a value, which is why it sits after return. Each prong — Zig’s term for one case => value arm of a switch — matches a tag with the shorthand .circle rather than Shape.circle, because Zig infers the enum type from context. The |r| and |rect| captures are the important part: inside a prong, the capture gives you the payload of that variant, already typed correctly. There is no cast, no manual tag check and no way to read the wrong field.
main() builds an array of three shapes. The literal .{ .circle = 2.0 } sets the tag and the payload in one step, and .point on its own is enough for the payload-free variant. The loop prints each shape with @tagName(), a builtin that returns the active tag as a string.
Capturing with |*r| gives a pointer to the payload instead of a copy, so r.* += 9.0 writes back into counter, which must therefore be a var. The else prong catches every remaining tag; without it, this switch would not compile.
Comment out the .point prong in area() and the compiler stops you:
$ zig run shapes.zig
shapes.zig:9:16: error: switch must handle all possibilities
return switch (self) {
^~~~~~
shapes.zig:6:5: note: unhandled enumeration value: 'point'
Add a variant to Shape later and every switch that forgot about it becomes a compile error rather than a bug. Reach for a tagged union whenever you would otherwise write a struct with a “type” field and a pile of optional members.
Save the code as shapes.zig and try it:
$ zig version
0.16.0
$ zig run shapes.zig
circle area = 12.566
rectangle area = 12.000
point area = 0.000
after mutation: 314.159
Happy coding in Zig!