zig / under the hood
DUE 000 DECK 000 STREAK 0d LESSONS 0/14 saved in this browser only
0x0A
Part II · Zig

Errors, optionals & defer

Errors are values you must handle, and defer keeps cleanup next to acquisition.

How errors usually work, and Zig’s choice

In JavaScript, errors are exceptions: throw jumps out of the current function and keeps unwinding until some try/catch catches it. That’s convenient, but any function call might throw, and nothing in the code tells you which ones. Zig makes errors values. A function that can fail says so in its return type, and the caller has to deal with it: handle it, or pass it upward explicitly. Nothing jumps away invisibly. If you’ve met Either in Scala or Result in Rust, this is the same idea built into the language.

Error sets and error unions

An error set is a list of named errors, a bit like an enum:

const ParseError = error{ Empty, TooLong };

fn parseName(s: []const u8) ParseError![]const u8 {
    if (s.len == 0) return error.Empty;
    if (s.len > 32) return error.TooLong;
    return s;
}

The return type ParseError![]const u8 is an error union: the function returns either one of those errors or a []const u8. Read the ! as “or”. Individual errors are written error.Name. Usually you let the compiler work out the set for you: fn f() !void means “returns nothing, or some error”, and the compiler collects every error the function can actually return.

Handling an error: try, catch, switch

Calling a function that returns an error union gives you an error union, and Zig won’t let you use the value inside until you’ve dealt with the error side. There are three ways. 1. Pass it up with try. try means “if this is an error, return it from my function too; otherwise give me the value”.

fn greet(input: []const u8) !void {
    const name = try parseName(input); // on error, greet returns that error
    std.debug.print("hi {s}\n", .{name});
}

try x is exactly shorthand for x catch |err| return err. Your own function must return an error union for this to compile. 2. Handle it right there with catch.

const name = parseName(input) catch "stranger"; // fall back to a default

const other = parseName(input) catch |err| {
    std.debug.print("bad name: {s}\n", .{@errorName(err)});
    return;
};

3. React to each error with switch.

const name = parseName(input) catch |err| switch (err) {
    error.Empty => "anonymous",
    error.TooLong => input[0..32],
};

Because the error set is known, the compiler checks that your switch covers every error. Add a new error to ParseError later, and every switch that forgot about it becomes a compile error.

Error return traces

When an error travels up through several trys and escapes main, a Debug build prints an error return trace: every place the error passed through, with file and line number. Exceptions in other languages show you where the error was thrown; Zig shows you the whole path it took on the way up, which is often more useful.

defer: cleanup you can’t forget

Systems code is full of “acquire, use, release”: open a file and close it, allocate memory and free it, lock and unlock. The release is easy to forget, especially on early returns and error paths. defer schedules a statement to run when the current block ends, no matter how it ends: normal finish, early return, or error.

const file = try std.fs.cwd().openFile("notes.txt", .{});
defer file.close(); // runs when this scope exits, whatever happens
// ... read the file, maybe return early, maybe hit an error ...

The habit is: acquire on one line, defer the release on the very next line, and you never have to think about it again. If you write several defers, they run in reverse order, last one first. That’s what you want: resources get released in the opposite order to how they were acquired.

errdefer: cleanup only on failure

Sometimes you want to clean up only if something fails. In this function, if the second allocation fails we must free the first one. But if everything succeeds, the caller now owns both buffers, so we must not free them:

fn makeBuffers(gpa: std.mem.Allocator) !struct { a: []u8, b: []u8 } {
    const a = try gpa.alloc(u8, 100);
    errdefer gpa.free(a); // runs only if something below fails
    const b = try gpa.alloc(u8, 100);
    return .{ .a = a, .b = b };
}

errdefer runs only when the scope exits by returning an error. This is how Zig code stays leak-free without destructors or a garbage collector.

Optionals: maybe a value

An optional ?T is either a value of type T, or null. It’s for “missing” rather than “failed”: looking up a key that isn’t in a map, searching a list for something that isn’t there.

const maybe: ?u32 = map.get("zig");

if (maybe) |value| { // runs only if not null; value is a plain u32
    std.debug.print("{d}\n", .{value});
}
const v = maybe orelse 0; // use 0 if it's null
const w = maybe.?; // "I'm sure it isn't null"; panics in safe builds if it is

Use .? sparingly, since it’s an assertion. Prefer if or orelse. When to use which: use an error when something went wrong and the caller may want to know why. Use an optional when “nothing there” is a normal, expected outcome.

Recap

  • Errors are values listed in the return type: E!T means an error from E, or a T.
  • try passes an error up; catch handles it locally; switch handles each error, checked by the compiler.
  • defer runs on any scope exit, in reverse order. errdefer runs only on error exits.
  • Acquire, then defer the release on the next line.
  • ?T is a T or null. Unwrap with if, orelse, or .?.
Try it

Write fn parsePort(s: []const u8) !u16 using std.fmt.parseInt that returns error.ReservedPort for anything below 1024. Call it from main with try on bad input and read the error return trace.

12 flashcards for this lesson

not started
See the questions
  1. What does try foo() expand to?
  2. defer vs errdefer?
  3. In what order do multiple defers run?
  4. E!T vs ?T?
  5. Three ways to unwrap an optional opt?
  6. What is an error return trace?
  7. What does fn f() !void mean?
  8. Why put errdefer right after an allocation?
  9. How is Zig's error handling different from JavaScript exceptions?
  10. When should you use an optional instead of an error?
  11. How is Zig's error handling different from JavaScript exceptions?
  12. When should you use an optional instead of an error?