Optionals & Unions
Handling Absence and Variant Types
Zig provides two powerful type system features for modeling data that can take on different forms: optionals for values that might be absent, and tagged unions for values that can be one of several types. Together, they eliminate entire categories of bugs that plague C and similar languages.
Optional Types
An optional type ?T can hold either a value of type T or null. This is Zig's replacement for null pointers and sentinel values:
var maybe_number: ?i32 = 42;
maybe_number = null; // Now it holds no value
Unwrapping Optionals with orelse
The orelse operator provides a default value when the optional is null:
const value: ?i32 = null;
const result = value orelse 0; // result is 0
const other: ?i32 = 42;
const result2 = other orelse 0; // result2 is 42
Unwrapping with if
You can use if to unwrap an optional and access its payload:
const maybe: ?i32 = 42;
if (maybe) |val| {
std.debug.print("Got: {}\n", .{val});
} else {
std.debug.print("Nothing\n", .{});
}
The variable val only exists inside the if block and contains the unwrapped value. This pattern guarantees you never accidentally use null.
Like Schrodinger's tribble, an optional value both exists and does not exist until you unwrap it. At least in Zig, the compiler makes sure you check before petting it.
Optional Pointers
Optional pointers ?*T are particularly useful. Unlike C, where any pointer might be null, Zig's *T is guaranteed non-null. When you need a nullable pointer, you opt in explicitly with ?*T:
fn findFirst(items: []const i32, target: i32) ?usize {
for (items, 0..) |item, i| {
if (item == target) return i;
}
return null;
}
Tagged Unions
A tagged union can hold one of several types, and Zig tracks which one is active. This is similar to sum types or algebraic data types in functional languages:
const Shape = union(enum) {
circle: f64, // radius
rectangle: struct { width: f64, height: f64 },
triangle: struct { base: f64, height: f64 },
};
Creating Union Values
const s1 = Shape{ .circle = 5.0 };
const s2 = Shape{ .rectangle = .{ .width = 4.0, .height = 3.0 } };
Switching on Unions
The switch statement is the primary way to work with tagged unions. Zig requires that you handle every variant, preventing you from forgetting a case:
fn area(shape: Shape) f64 {
return switch (shape) {
.circle => |r| std.math.pi * r * r,
.rectangle => |rect| rect.width * rect.height,
.triangle => |tri| 0.5 * tri.base * tri.height,
};
}
If you add a new variant to the union later, the compiler will force you to handle it in every switch statement. This is one of the strongest guarantees a type system can provide.
Combining Optionals and Unions
You can return optional union values, giving you expressive APIs:
fn parseCommand(input: []const u8) ?Command {
if (std.mem.eql(u8, input, "quit")) return .quit;
return null;
}
Your Task
Part 1 — Optionals: Write a function safeDivide(a: i32, b: i32) ?i32 that returns a / b as an optional. If b is zero, return null.
For division, use Zig's @divTrunc(a, b) builtin, which performs integer division and truncates toward zero. Zig does not allow the / operator on signed integers because the truncation behavior is implicit; @divTrunc makes it explicit.
Part 2 — Tagged Unions: Define a tagged union NumberKind with three variants:
.positive: i32— stores the positive value.negative: i32— stores the negative value.zero: void— no payload
Then write a function classifyNumber(n: i32) NumberKind that returns the appropriate variant. Finally, write a function describeKind(kind: NumberKind) []const u8 that switches on the union and returns "positive", "negative", or "zero".