Error Handling In Zig

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.

Comments

matklad

If unreachable is reached, the panic handler is executed.

This is not entirely correct, unreachable is a checked illegal behavior, meaning that in safe modes (Debug or ReleaseSafe) it indeed is defined to panic, but in unsafe modes (ReleaseFast or ReleaseSmall) reaching unreachable is illegal, and can lead to arbitrary behavior.

To invoke the standard panic handler you should use @panic() not unreachable as otherwise your program reaches undefined behaviour upon error. In tests one should use try instead as that allows multiple tests to run rather than crashing the process.

JPL

Hello, this is very interesting, I will most definitely implement for my JSON processing THANKS

Wojciech

Amazing article, thanks for publishing!

Vanag

I do not understand this part

allocator.alignedAlloc(u8, CONN_ALIGNOF, CONN_SIZEOF);

karlseguin

You normally only need to use the "alloc" or "create" methods of the allocator. alignedAlloc is an advance use-case where, instead of creating memory for a type, you're directly allocating the bytes of memory aligned a certain way.

Normally, when you use allocator.create(T), Zig knows the @sizeOf(T) and the @alignOf(T). But in the code these examples came from, T was a structure from C. At the time that I wrote this, Zig's translate-c wasn't able able to infer the @alignOf(T) and @sizeOf(T) for this particular C-structure. This is no longer the case though, and translate-c is now able to handle this particular case. So, in the actual code, it's now just allocator.create(Conn).

Leave a Comment

All comments are reviewed before being made public.