Mail me

Error handling and the "diagnostics" pattern in Zig

Zig has an error set type, which is basically a global enum: you can just go and use error.MyCustomError and the compiler will assign it a unique integer, and then if you use it anywhere else it'll translate to the same integer. You can define error sets, which can contain several different error values, for example you can do const MyError = error{A, B} and then write MyError.A (which is the same as writing error.A).

Functions can return errors, and there's some special syntax for that. A function that returns an error will have a type like MyError!u16, where MyError is an error set containing all possible error values, and u16 is the type the function returns if there's no error. This is just like Go's error, result pattern except with actual syntax. Or like Rust's Result type, except that Zig is a little less annoying about having to define the error type: very often you can just write !u16 and the compiler will infer the error type.

There's a little bit more to errors (for example you can do try just like in Rust) but as far as features this is about all you get. This leaves open the question of how to attach contextual information for the purposes of reporting. For example Go has error wrapping, and Rust has libraries like anyhow that enable you to add context to errors.

There isn't exactly a well-defined "best practice" on how to do this but a popular approach seems to be the "diagnostics" pattern. I don't know if "diagnostics" is an established term but several places in std use it (std.json, for example).

Anyway the idea is quite simple: you create a struct named Diagnostics to store contextual information, which can be for example an error message, or, for something like a parser, line/column numbers. Functions that can return the errors you're interested in will take an additional argument: a pointer to the Diagnostics struct. The caller is responsible for allocating space for the struct and keeping track of its lifetime. If the function returns an error, the caller can inspect the Diagnostics struct and react accordingly. For a very contrived example:

  const Diagnostics = struct {
      line_number: usize,
  };

  fn add(self: *Parser, next_count: u64, diagnostics: ?*Diagnostics) error.Overflow!u64 {
      const sum = @addWithOverflow(self.count, next_count);
      if (sum[1] != 0) {
          if (diagnostics) |diag| {
              diag.line_number = self.current_line_number;
          }
          return error.Overflow;
      }
      self.count = sum[0];
      return self.count;
  }

(You can of course add the pointer to the relevant struct, Parser in this case, so you don't need to pass it to every function.)

So it is a pretty simple idea. When I first started looking into this I was surprised that there isn't more in the language itself to support this pattern, but it makes sense that they wouldn't want to add something that incurred hidden allocations, which is kind of a core Zig principle. This pattern is probably also not unfamiliar to C programmers since this is basically the only way to have error handling beyond returning error codes. (See for example glib).