There are two aspects to Zig's simple error handling: error sets and try/catch. Zig's error set are essentially a specialized enum that can be created implicitly:
fn divide(dividend: u32, divisor: u32) !u32 {
if (divisor == 0) {
return error.DivideByZero;
}
return dividend / divisor;
}
Which is, in effect, the same as making it explicit:
const DivideError = error {
DivideByZero
};
fn divide(dividend: u32, divisor: u32) !u32 {
if (divisor == 0) {
return DivideError.DivideByZero;
}
return dividend / divisor;
}
In both cases our function returns an !u32
where the exclamation mark, !
, indicates that this function can return an error. Again, we can either be explicit about the error(s) that our function returns or implicit. In both of the above case, we've taken the implicit route.
Alternatively, we could have used an explicit return type: error{DivideByZero}!u32
. In the 2nd case we could have used the explicit error set: DivideError!u32
.
Zig has a special type, anyerror
, which can represent any error. It's worth pointing out that the return types !u32
and anyerror!u32
are different. The first form, the implicit return type, leaves it up to the compiler to determine what error(s) our function can return. There are cases where the compiler might not be able to infer type where we'll need to be explicit, or, if you're lazy like me, use anyerror
. It's worth pointing out that the only time I've run into this is when defining a function pointer type.
Calling a function that returns an error, as divide
does, requires handling the error. We have two options. We can use try
to bubble the error up or use catch
to handle the error. try
is like Go's tedious if err != nil { return err }
:
pub fn main() !void {
const result = try divide(100, 10);
_ = result; // use result
}
This works because main
itself returns an error via !void
. catch
isn't much more complicated:
const result = divide(100, 10) catch |err| {
// handle the error, maybe log, or use a default
}
For more complicated cases, a common pattern is to switch on the caught error. From the top-level handler in http.zig:
self.dispatch(action, req, res) catch |err| switch (err) {
error.BodyTooBig => {
res.status = 431;
res.body = "Request body is too big";
res.write() catch return false;
},
error.BrokenPipe, error.ConnectionResetByPeer => return false,
else => self._errorHandler(req, res, err),
}
Another somewhat common case with catch
is to use catch unreachable
. If unreachable
is reached, the panic handler is executed. Applications can define their own panic handler, or rely on the default one. I use catch unreachable
a lot in test code.
One final thing worth mentioning is errdefer
which is like defer
(think Go's defer
) but it only executes if the function returns an error. This is extremely useful. You can see an example of it in the duckdb driver
pub fn open(db: DB) !Conn {
const allocator = db.allocator;
var slice = try allocator.alignedAlloc(u8, CONN_ALIGNOF, CONN_SIZEOF);
// if we `return error.ConnectFail` a few lines down, this will execute
errdefer allocator.free(slice);
const conn: *c.duckdb_connection = @ptrCast(slice.ptr);
if (c.duckdb_connect(db.db.*, conn) == DuckDBError) {
// if we reach this point and return an error, our above `errdefer` will execute
return error.ConnectFail;
}
// ...
}
A serious challenge with Zig's simple approach to errors that our errors are nothing more than enum values. We cannot attach additional information or behavior to them. I think we can agree that being told "SyntaxError" when trying to parse an invalid JSON, with no other context, isn't great. This is currently an open issue.
In the meantime, for more complex cases where I want to attach extra data to or need to attach behavior, I've settled on leveraging Zig's tagged unions to create generic Results. Here's an example from a PostgreSQL driver I'm playing with:
pub fn Result(comptime T: type) T {
return union(enum) {
ok: T,
err: Error,
};
}
pub const Error = struct {
raw: []const u8, // fields all reference this data
allocator: Allocator, // we copy raw to ensure it outlives the connection
code: []const u8,
message: []const u8,
severity: []const u8,
pub fn deinit(self: Error) void {
self.allocator.free(self.raw);
}
};
Because Zig has exhaustive switching, we can still be sure that callers will have to handle errors one way or another. However, using this approach means that the convenience of try
, catch
and errdefer
are no longer available. Instead, you'd have to do something like:
const conn = switch (pg.connect()) {
.ok => |conn| conn,
.err => |err| // TODO handle err
}
The "TODO handle err" is going to be specific to the application, but you could log the error and/or convert it into a standard zig error.
In our above Error
you'll note that we've defined a deinit
function to free self.raw
. So this is an example where our error has additional data (the code
, message
and severity
) as well as behavior (freeing memory owned by the error).
To compensate for our loss of try
and catch
, we can enhance or Result(T)
type. I haven't come up with anything great, but I do tend to add an unwrap
function and a deinit
function (assuming either Error
or T
implement deinit
):
pub fn unwrap(self: Self) !T {
switch(self) {
.ok => |ok| return ok,
.err => |err| {
err.deinit();
return error.PG;
}
}
}
pub fn deinit(self: Self) void {
switch (self) {
.err => |err| err.deinit(),
.ok => |value| {
if (comptime std.meta.trait.hasFn("deinit")(T)) {
value.deinit();
}
},
}
}
Unlike Rust's unwrap
which will panic on error, I've opted to convert Error
into an generic error (which can then be used with Zig's built-in facilities). As for exposing deinit
directly on Result(T)
it just provides an alternative way to program against the result. Combined, we can consume Result(T)
as such:
const result = pg.connect();
defer result.deinit(); // will be forwarded to either `T` or `Error`
const conn = try result.unwrap();
In the end, the current error handling is sufficient in most cases, but I think anyone who tries to parse JSON using std.json
will immediately recognize a gap in Zig's capabilities. You can work around those gaps by, for example, introducing a Result
type, but then you lose the integration with the language. Of particular note, I would say losing errdefer
is more than just an ergonomic issue.