TL;DR
Learn the Zig optional type and null safety — use ?T to represent nullable values, unwrap safely with orelse and if, and preview tagged unions for values with more than two shapes.
Key concepts
- zig optional type
- zig null safety
- zig tagged unions
- zig orelse
- zig nullable
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.
Two-tier handoff: this document is the complete reading surface. Continue learning for stateful practice, progress, and real sandbox execution.