Errors
Zig has no exceptions. A function that can fail returns an error union,
written E!T: either an error from set E, or a T.
const std = @import("std");
const expect = std.testing.expect;
const FileOpenError = error{
AccessDenied,
OutOfMemory,
FileNotFound,
};
// Error sets coerce into supersets, so a narrow error can be returned
// from a function that declares a wider set.
const AllocationError = error{OutOfMemory};
test "error set coercion" {
const err: FileOpenError = AllocationError.OutOfMemory;
try expect(err == FileOpenError.OutOfMemory);
}
fn failingFunction() error{Oops}!void {
return error.Oops;
}
test "catch supplies a fallback" {
// `!u8` is shorthand for "some inferred error set, or u8".
const parsed = std.fmt.parseInt(u8, "not a number", 10) catch 0;
try expect(parsed == 0);
}
test "try propagates" {
// `try x` is `x catch |err| return err`.
try std.testing.expectError(error.Oops, failingFunction());
}
test "capture the error in catch" {
var captured: anyerror = undefined;
failingFunction() catch |err| {
captured = err;
};
try expect(captured == error.Oops);
}
fn parseOrDefault(text: []const u8) u32 {
// `if` can unwrap an error union too, with `else |err|`.
if (std.fmt.parseInt(u32, text, 10)) |value| {
return value;
} else |_| {
return 0;
}
}
test "if on an error union" {
try expect(parseOrDefault("42") == 42);
try expect(parseOrDefault("abc") == 0);
}Error sets are types
const FileOpenError = error{ AccessDenied, OutOfMemory, FileNotFound };
A narrow set coerces into a wider one, so a function returning
error{OutOfMemory} can be called from one returning FileOpenError without
ceremony. anyerror is the set of all errors: convenient, but it discards
exactly the information that makes this system useful.
Sets combine with ||, which is how a function that calls two subsystems
declares that it can fail in either way:
const Error = ParseError || std.Io.Reader.Error;
Writing !T with no set at all asks the compiler to infer it from every error
the body can produce. That is the right default inside a program, where the
set is whatever it is. It is the wrong default at a library’s public boundary,
because the inferred set is part of your interface and changes silently when
the body changes.
An error is just a value with a name. It carries no payload, no message and no stack. The gain is that an error set is comparable, switchable, and costs nothing to return. When a failure needs detail, the detail goes somewhere the caller can ask for it, not into the error.
Four ways to handle one
| Form | Meaning |
|---|---|
try x | unwrap, or return the error to my caller |
x catch fallback | unwrap, or use this value instead |
x catch |err| { ... } | unwrap, or handle the error here |
if (x) |v| ... else |err| ... | branch on both outcomes |
try is by far the most common, and it is only shorthand: x catch |err| return err.
To handle some errors and pass on the rest, switch inside the catch. The
switch is exhaustive over the error set, so adding a new failure mode to a
function you call becomes a compile error in every caller that was enumerating
them:
const config = load(path) catch |err| switch (err) {
error.FileNotFound => Config.default,
else => return err,
};
catch unreachable asserts a call cannot fail. It is checked in safety builds
and undefined behaviour in ReleaseFast, so it belongs where the invariant is
local and visible, not where it silences an inconvenient signature.
Errors that need cleanup
errdefer runs only on the error path, which is what makes a multi-step
initialiser correct without duplicating the cleanup into every catch. It is
covered in defer, and it is the piece that
makes the whole scheme work. try returns immediately from the middle of a
function. errdefer is what you arranged in advance to happen when it does.
Finding out where it came from
Because an error carries no stack, Zig builds an error return trace
separately in Debug and ReleaseSafe. Letting an error escape main prints the
chain of try sites it passed through on the way up. That is the information
a stack trace would have given you, and often better: it shows where the error
was produced, not where it was noticed. It costs nothing in ReleaseFast, where
the trace is not built at all.
For logging one yourself, @errorName(err) gives the name as a string, and
the {!} format specifier prints an error union directly, as
error.FileNotFound.
Why this beats exceptions
The failure paths are in the type signature and in the control flow you can
see. There is no invisible unwinding, no hidden second exit from every line.
That is the same reason defer is needed and sufficient for cleanup.
Exceptions let the happy path stay clean and push error handling to a
distance. Error unions put a try on every fallible call. What you get for the
noise is that every one of those trys is a place the reader can see the
function might leave. And no function can fail in a way its signature did not
admit.