notes/Media/articles/Error Handling in Zig.md

7.0 KiB

author link date
https://www.aolium.com/karlseguin/4013ac14-2457-479b-e59b-e603c04673c8 August 7, 2023

Error Handling In Zig

#programming #error-handling #zig #enum #error-set There are two aspects to Zigs simple error handling: error sets and try/catch. Zigs 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, weve 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. Its 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 well need to be explicit, or, if youre lazy like me, use anyerror. Its worth pointing out that the only time Ive 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 Gos 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 isnt 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 Gos 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 Zigs 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, isnt 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, Ive settled on leveraging Zigs tagged unions to create generic Results. Heres an example from a PostgreSQL driver Im 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, youd 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 youll note that weve 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 havent 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 Rusts unwrap which will panic on error, Ive opted to convert Error into an generic error (which can then be used with Zigs 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 Zigs 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.