Skip to editor content
learningzig.orglesson 9 of 25

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 ?T is either a value or null — no null pointer surprises
  • Use if (optional) |value| for safe unwrapping
  • orelse provides a default when the optional is null
  • .? force-unwraps an optional, crashing on null — 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