Optionals and Unions
Zig's optional types and tagged unions make null safety and variant data explicit in the type system. This prevents null pointer errors and makes your code self-documenting.
Optional Types
An optional ?T can hold either a value of type T or null.
const std = @import("std");
fn findIndex(haystack: []const u8, needle: u8) ?usize {
for (haystack, 0..) |c, i| {
if (c == needle) return i;
}
return null;
}
pub fn main() !void {
const text = "Hello, World!";
// Check with if
if (findIndex(text, 'W')) |index| {
std.debug.print("Found 'W' at index {}\n", .{index});
} else {
std.debug.print("'W' not found\n", .{});
}
if (findIndex(text, 'Z')) |index| {
std.debug.print("Found 'Z' at index {}\n", .{index});
} else {
std.debug.print("'Z' not found\n", .{});
}
// orelse — provide a default value
const idx = findIndex(text, 'x') orelse 999;
std.debug.print("Index with default: {}\n", .{idx});
// .? — unwrap (crashes if null, use carefully)
const known_idx = findIndex(text, 'H').?;
std.debug.print("Known index of 'H': {}\n", .{known_idx});
}
Optional Chaining
Combine optionals with control flow for safe data access.
const std = @import("std");
const User = struct {
name: []const u8,
email: ?[]const u8,
age: ?u32,
};
fn getUserEmail(user: ?User) ?[]const u8 {
const u = user orelse return null;
return u.email;
}
fn formatUser(user: User) void {
std.debug.print("Name: {s}\n", .{user.name});
if (user.email) |email| {
std.debug.print("Email: {s}\n", .{email});
} else {
std.debug.print("Email: (not provided)\n", .{});
}
if (user.age) |age| {
std.debug.print("Age: {}\n", .{age});
} else {
std.debug.print("Age: (not provided)\n", .{});
}
}
pub fn main() !void {
const alice = User{ .name = "Alice", .email = "alice@example.com", .age = 30 };
const bob = User{ .name = "Bob", .email = null, .age = null };
formatUser(alice);
std.debug.print("\n", .{});
formatUser(bob);
std.debug.print("\nAlice's email: {s}\n", .{getUserEmail(alice) orelse "unknown"});
std.debug.print("Bob's email: {s}\n", .{getUserEmail(bob) orelse "unknown"});
}
When Two States Aren't Enough: A Look Ahead
An optional is really the simplest possible "sum type": it holds either a value or null — exactly two possibilities. But real data often has more than two shapes. A sensor reading might be a valid number, or pinned out of range, or disconnected entirely. Squeezing that into an optional loses information.
Zig's answer is the tagged union (union(enum)): a value that holds one of several types at a time and always tracks which one is active. It is the natural next step up from an optional.
const std = @import("std");
// An optional has exactly two states: a value, or null.
// When you need MORE than two states — each carrying its own data —
// reach for a tagged union.
const Reading = union(enum) {
ok: f64, // a valid measurement
out_of_range, // sensor pinned at its limit
disconnected, // no sensor present
};
fn describe(r: Reading) void {
switch (r) {
.ok => |value| std.debug.print("reading: {d:.1}\n", .{value}),
.out_of_range => std.debug.print("reading: out of range\n", .{}),
.disconnected => std.debug.print("reading: disconnected\n", .{}),
}
}
pub fn main() !void {
const readings = [_]Reading{
.{ .ok = 21.5 },
.out_of_range,
.disconnected,
};
for (readings) |r| describe(r);
}
Notice how the switch captures the payload with |value| — the same syntax you just used to unwrap an optional with if (opt) |val|. That is not a coincidence: an optional behaves like a two-variant tagged union under the hood.
This is only a taste. Tagged unions have their own full lesson later in the track, covering enum tags, @tagName, methods on unions, and exhaustive dispatch in depth. For now, remember the mental model: when you need more than "value or nothing," reach for a tagged union.
You'll cover this in full in Enums And Tagged Unions, where tagged unions get the complete treatment alongside enums.
Key Takeaways
- Optional
?Tis either a value ornull— no null pointer surprises - Use
if (optional) |value|for safe unwrapping orelseprovides a default when the optional is null.?force-unwraps an optional, crashing onnull— use it only when a value is guaranteed- When you need more than "value or nothing," a tagged union (
union(enum)) lets a value take one of several shapes — covered fully in lesson 16
Pro Tip: Prefer optionals over sentinel values (like -1 or empty strings) to represent "no value." The type system forces callers to handle the null case, while a sentinel value can be silently ignored. When you need more than two states, upgrade from optional to a tagged union.
Next Steps
You've seen how optionals and unions represent data with multiple possible shapes. Next up, you'll learn how slices give you flexible, zero-cost views into arrays and how to iterate over them efficiently.
Next lesson
Slices and Iteration
Master Zig slices and iteration — use fat pointers for safe array views, iterate with for loops, and leverage std.mem utilities for searching and splitting.
22 min