Lesson 9 / 25

Errors, Optionals, try, catch and errdefer

Handle failures with error unions and missing values with optionals.

Errors are values

Zig has no exceptions. A function that can fail returns an error union E!T: either a value of type T or an error from the error set E. Error sets are declared like const ParseError = error{ Empty, InvalidQuantity };, and !T alone asks the compiler to infer the error set. Errors are lightweight values (like enum tags), and they cannot be ignored: you must handle them. try expr returns the error to the caller if expr fails (like ? in Rust); catch handles it (parse(s) catch 0 provides a default, catch |err| switch (err) { ... } handles each case, catch return error.X converts); and if (result) |value| ... else |err| ... branches on success. Since error sets are known at compile time, a switch on an error can be exhaustive. Optionals ?T represent absence: null or a value, unwrapped with orelse (default), if (opt) |v|, or .? (assert non-null). defer runs code when the scope exits, and errdefer runs it only if the scope exits with an error, which is the key tool for cleaning up partially constructed results. In Debug and ReleaseSafe builds, error return traces show where an error originated.

Parsing an order line with error unions

Custom error sets, try, catch with a switch and orelse.

const std = @import("std");

const OrderError = error{ EmptyLine, MissingField, InvalidQuantity, QuantityTooLarge };

const OrderLine = struct { sku: []const u8, qty: u16 };

fn parseLine(line: []const u8) OrderError!OrderLine {
    const trimmed = std.mem.trim(u8, line, " \t\r");
    if (trimmed.len == 0) return error.EmptyLine;

    var parts = std.mem.splitScalar(u8, trimmed, ':');
    const sku = parts.next() orelse return error.MissingField;
    const qty_text = parts.next() orelse return error.MissingField;

    const qty = std.fmt.parseInt(u16, qty_text, 10) catch |err| switch (err) {
        error.Overflow => return error.QuantityTooLarge,
        error.InvalidCharacter => return error.InvalidQuantity,
    };
    return .{ .sku = sku, .qty = qty };
}

fn totalQuantity(lines: []const []const u8) OrderError!u32 {
    var total: u32 = 0;
    for (lines) |line| {
        const parsed = try parseLine(line);          // propagate any error to the caller
        total += parsed.qty;
    }
    return total;
}

pub fn main() void {
    const good = [_][]const u8{ "pen:2", "ink:1", "pad:5" };
    const bad = [_][]const u8{ "pen:2", "ink:lots" };

    const t = totalQuantity(&good) catch 0;
    std.debug.print("total qty: {d}\n", .{t});      // 8

    if (totalQuantity(&bad)) |qty| {
        std.debug.print("qty {d}\n", .{qty});
    } else |err| {
        std.debug.print("could not parse order: {s}\n", .{@errorName(err)});   // InvalidQuantity
    }

    const maybe_discount: ?u8 = null;
    std.debug.print("discount: {d}%\n", .{maybe_discount orelse 0});
}

Prefer explicit error sets on public APIs

Inferred error sets (!T) are convenient inside a module, but naming the set on public functions documents exactly what callers must handle and keeps exhaustive switches meaningful.

Quick check: What does `try foo()` do if foo returns an error?

  • Ignores the error
  • Panics
  • Returns that error from the current function immediately
  • Retries foo
Answer

Returns that error from the current function immediately — try is shorthand for catching the error and returning it.