Skip to lesson

learningzig.org / intermediate / 09-optionals-and-unions · lesson 7 of 25

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.

What You'll Learn

By the end of this lesson you will write functions whose return type admits that they might not find anything — a lookup that comes up empty, a field that was never filled in — and you will handle that absence at the call site without a crash and without a sentinel value like -1 standing in for "nothing". The build task asks you to work over rows where some fields are genuinely missing and to keep going rather than give up on the whole batch.

This is the other half of the error-pipeline milestone the record analyzer needs. The last lesson gave you a way to report a failure; this one gives you a way to represent an absence, and the analyzer needs both, because a row with no note attached is not a broken row. You arrive here able to return a named failure from a function and unwrap it with try or catch (Error Handling); what is new is the case where nothing went wrong and there is simply nothing there.

Optional Types

An optional ?T can hold either a value of type T or null.

zig
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});
}

The orelse default above is a plain number, so its cost is invisible. Make the default a function call and the question of when it is evaluated becomes observable. Work it out before running.

Predict

The default on the right of orelse is a function that announces itself. lookup(1) returns a value; lookup(2) returns null. What gets printed, in order?

zig
const std = @import("std");

fn lookup(id: u32) ?u32 {
  if (id == 1) return 100;
  return null;
}

fn fallback() u32 {
  std.debug.print("fallback ran\n", .{});
  return 0;
}

pub fn main() !void {
  const a = lookup(1) orelse fallback();
  std.debug.print("a={}\n", .{a});

  const b = lookup(2) orelse fallback();
  std.debug.print("b={}\n", .{b});
}

Continue learning

The order is a=100, then fallback ran, then b=0. orelse short-circuits: with a non-null left side the right side is never evaluated at all, so fallback() runs exactly once — on the null branch. That laziness is what makes an expensive or side-effecting default safe to write there.

Now assemble one. The lines below are the body of a function that turns a row into a one-line summary, where the score may be missing — shuffled, with only one arrangement the compiler will even accept.

Arrange the code

These lines form a function that summarises a row whose score field is an optional. Reorder them so it prints a summary when the score is present and a plain message when it is not — then say why this order is forced, and what specifically the guard line has to happen before.

  1. const text = std.fmt.bufPrint(buf, "{s}: {} ({c})", .{ row.name, score, grade }) catch return "too long";
  2. return text;
  3. const grade: u8 = if (score >= 50) 'P' else 'F';
  4. const score = row.score orelse return "no score recorded";
  5. fn summarise(row: Row, buf: []u8) []const u8 {
Continue learning

Optional Chaining

Combine optionals with control flow for safe data access.

zig
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"});
}

Every unwrap above either handled the null branch or was applied to a value the author could prove was there. Here is what it costs to get that proof wrong.

Debug

This should print a note length for each row, using 0 for the row with no note. It prints the first row and then dies mid-run. Commit a hypothesis about which line kills it and why, then fix it so both rows print.

zig
const std = @import("std");

const Row = struct {
  id: u32,
  note: ?[]const u8,
};

fn noteLength(row: Row) usize {
  return row.note.?.len;
}

pub fn main() !void {
  const rows = [_]Row{
      .{ .id = 1, .note = "checked" },
      .{ .id = 2, .note = null },
  };
  for (rows) |row| {
      std.debug.print("row {} note length: {}\n", .{ row.id, noteLength(row) });
  }
}

Expected output:

row 1 note length: 7
row 2 note length: 0
Continue learning

.? is an assertion, not a check: it says "I know this is non-null." Row 1 holds "checked" so the assertion holds, but row 2's note really is null, so the assertion fails at runtime with panic: attempt to use null value and the program aborts mid-loop. Handling the null branch fixes it — const note = row.note orelse return 0;, or an if (row.note) |note| capture with an else that returns 0. Reach for .? only where a null is genuinely impossible and crashing is preferable to continuing; when the ? in the type says the null is real, orelse or a capture is the honest choice.

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.

zig
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.

Zig now has three ways to say "this might not be a plain value", and they are not interchangeable. Close the code and see whether you can say which one a given situation calls for.

Recall

Without scrolling up: in Error Handling you learned that ErrorSet!T means 'a value or a named failure', and in this lesson ?T means 'a value or nothing'. A lookup function searches a list for a name and does not find it. Which return type is right, and why?

Continue learning

?T is the right choice: a search that comes up empty is a normal outcome, and there is only one way to be absent, so null says everything there is to say. An error union earns its extra machinery when there are several distinct failures a caller would want to tell apart, and a tagged union generalises both to "one of several shapes, each carrying its own data." Pick the smallest shape that carries the information the caller actually needs.

Optionals are not a Zig invention, and the language they most resemble here draws the same line in almost the same place. Almost is the interesting part.

Transfer

Zig writes a value that might be missing as ?i32 and makes you open it with orelse or an if capture before you can use the number. Rust writes the same thing as Option<i32> and makes you open it with match, if let, or unwrap_or. Which statement names the mechanism the two share, rather than a resemblance that does not hold?

Continue learning

Try It Yourself

Reading about optionals is not the same as choosing between them and their neighbours. This is a build task: a small program that reports its own pass/fail. Three functions are stubbed out — one returns an optional from a search, one dispatches over a tagged union, one collapses an optional to a default. Run it as-is and it fails immediately, naming the first check that did not pass. Implement each function until every check passes and it prints All checks passed.

There is no assert to import: the starter defines its own check helper that prints the failure and returns an error. Because an error returned from main ends the program with a non-zero exit status, the first failing check stops everything — no hidden control flow, exactly as Zig prefers it.

Build

Finish the build. Three functions are stubbed out and the checks below them fail until each returns the right value. Run it as-is to see which check fails first, decide whether that function should hand back an optional, a label, or a plain value, then implement all three until it prints 'All checks passed.' The checks run top to bottom, so the first failure you see is TODO 1 — implement it first, then work down.

zig
const std = @import("std");

// A tiny stand-in for an assert: print what went wrong and return an error.
// An error returned from main ends the program with a non-zero exit status.
fn check(ok: bool, comptime message: []const u8) !void {
  if (!ok) {
      std.debug.print("CHECK FAILED: {s}\n", .{message});
      return error.CheckFailed;
  }
}

// One parsed cell: a number, some text, or nothing at all.
const Field = union(enum) {
  number: i64,
  text: []const u8,
  missing,
};

// TODO 1: return the FIRST .number payload in the slice, or null when there
//   isn't one. Switch on each field and keep going past the other variants.
//   firstNumber(&.{ .missing, .{ .number = 41 } }) -> 41
//   firstNumber(&.{ .missing }) -> null
fn firstNumber(fields: []const Field) ?i64 {
  _ = fields;
  return null;
}

// TODO 2: name the variant with an EXHAUSTIVE switch — every prong listed,
//   no else. You need the tag only, not the payload.
//   .number -> "number", .text -> "text", .missing -> "missing"
fn describeField(f: Field) []const u8 {
  _ = f;
  return "";
}

// TODO 3: return the first number, or fallback when there is none. One line,
//   reusing TODO 1 — note the plain i64 return type, so no optional escapes.
//   numberOr(&.{ .missing }, -1) -> -1
fn numberOr(fields: []const Field, fallback: i64) i64 {
  _ = fields;
  _ = fallback;
  return 0;
}

// --- Build checks: Do not edit below this line. ---
pub fn main() !void {
  const rows = [_]Field{ .missing, .{ .text = "hello" }, .{ .number = 41 }, .{ .number = 9 } };
  const empty = [_]Field{ .missing, .{ .text = "x" } };

  try check(firstNumber(&rows) != null, "firstNumber should find a number in rows");
  try check(firstNumber(&rows).? == 41, "firstNumber should return 41 — the FIRST number, not the last");
  try check(firstNumber(&empty) == null, "firstNumber should be null when no field is a number");

  try check(std.mem.eql(u8, describeField(rows[1]), "text"), "describeField on a .text field should be \"text\"");
  try check(std.mem.eql(u8, describeField(rows[2]), "number"), "describeField on a .number field should be \"number\"");
  try check(std.mem.eql(u8, describeField(.missing), "missing"), "describeField on .missing should be \"missing\"");

  try check(numberOr(&rows, -1) == 41, "numberOr should return the found number 41");
  try check(numberOr(&empty, -1) == -1, "numberOr should fall back to -1 when there is no number");

  std.debug.print("All checks passed.\n", .{});
  std.debug.print("First number: {?}\n", .{firstNumber(&rows)});
  std.debug.print("Empty lookup: {?}\n", .{firstNumber(&empty)});
  std.debug.print("Label: {s}\n", .{describeField(rows[2])});
}

Expected output:

All checks passed.
First number: 41
Empty lookup: null
Label: number
Continue learning

Once it passes, try two variations and predict each before running:

  1. Keep scanning instead of returning early. In firstNumber, replace the return n inside the switch with an assignment to a var found: ?i64 = null; declared before the loop, and return found at the end. Predict which check fails before running. The presence and empty checks still pass, but the == 41 check fails with 9: every later number overwrites the earlier one, so you end up with the last number. Where you return from is what makes a search find the first match.
  2. Return the optional straight through. In numberOr, drop the orelse fallback and try return firstNumber(fields);. Predict what happens before running. It does not compile at all — the function promises a plain i64 and ?i64 will not coerce into it, so Zig reports a type mismatch and points at the return. The orelse is not decoration; it is the only thing bridging the two types.

Capstone milestone

Milestone — the error pipeline. The capstone's Record Analyzer meets fields that are absent as well as fields that are malformed, and the two are not the same problem: a blank cell is a normal optional, a garbled cell is an error. The field scanner you just built is the optional half of that pipeline. Confirm you can tell absence from failure and encode each in a type.

Hint: You don't need the full analyzer yet — this confirms the absence-versus-failure judgement the pipeline is built on. In the capstone, a missing column comes back as null, a malformed one comes back as a named error, and the report says something different for each.

  • Returned ?T from a search where 'not found' is a normal outcome, rather than a sentinel value
  • Unwrapped an optional safely with orelse or an if-capture instead of asserting with .?
  • Used an exhaustive switch over a tagged union with no else prong
  • Explained to yourself when ?T is right and when a named error set is right — absence versus a failure the caller must distinguish
Continue learning

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 Enums And Tagged Unions

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, tagged unions get the full treatment alongside enums -- named variants, backing integers, @tagName, and exhaustive dispatch the compiler checks for you.

Ready to try it? Continue in your learning studio to practice these ideas, save your progress, and experiment with code.