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!Tmeans an error from E, or a T. trypasses an error up;catchhandles it locally;switchhandles each error, checked by the compiler.deferruns on any scope exit, in reverse order.errdeferruns only on error exits.- Acquire, then
deferthe release on the next line. ?Tis a T or null. Unwrap withif,orelse, or.?.