Comparing error handling in Zig and Go
Errors are values
I've been using Go for probably 8 years, and it's been my go-to language for most of the projects. I think we can all agree that Go can be verbose sometimes, which attracts some people (like me) but also is a hot topic often. Specifically when it comes to error handling, when the big portion of your codebase might be pure error handling.
What's beautiful in Go about error handling is that errors are values, this simplifies passing them around, modifying them as you want, because as Rob Pike said:
Values can be programmed, and since errors are values, errors can be programmed.
- Rob Pike -
To summarize, a function in Go can return an error, or even multiple:
func Open(name string) (*File, error)
The users of this function can simply check for nil
error value:
f, err := os.Open("main.zig")
if err != nil {
// process the error
}
// one-liners are also possible
if err := f.Close(); err != nil {
log.Fatal(err)
}
error
type is an interface type, which makes it possible to create custom error types that can hold various data.
type errorString struct {
s string
}
func (e *errorString) Error() string {
return e.s
}
func New(text string) error {
return &errorString{text}
}
That's basically it, there is also an `errors` package that has some handy utilities for working with errors in Go. For example you can check the error type by doing:
if errors.Is(err, fs.ErrNotExist) {
// process error
}
You can read about error handling in Go in detail here, but as you probably noticed from the title of this article, we wanted to talk about Zig here as well, specifically about its error handling as well.
Few notes on Zig
Heads up, I'm still new to Zig, yes I wrote few dead simple programs, but I'm still learning.
I found some similarities between Go and Zig, as both languages have adopted a design philosophy of simplicity to remove the language out of the way to let you be productive quickly.
Zig, however, stays true to its "no hidden control flow" and requires a programmer to manage the memory allocations manually. I always programmed in languages with GC, so I was used to the fact that allocations happen behind the scenes.
Memory allocation can fail too, and Zig makes allocation failures explicit, so here is our segue into error handling in Zig!
Error handling in Zig: 101
Zig like Go treats errors like values, but does it through a specialized enum
that can be created implicitly.
Now, this is where things get interesting. The function below returns an !usize
where the exclamation mark !
indicates that this function can return an error. We can either be explicit about the error(s) that our function returns or implicit. In this case, we've taken the implicit route.
fn get_args_count(allocator: std.mem.Allocator) !usize {
const args = try std.process.argsAlloc(allocator);
if (args.len < 1) {
return error.EmptyArgs;
}
return args.len;
}
This code listing shows another mechanism of propagating an error up the call stack using the try
keyword. We must do that, because std.process.argsAlloc
might return an error, and all errors in Zig must be handled or we'll get a compile time error. The equivalent Go code for “const args = try std.process.argsAlloc(allocator);
“ would be:
args, err := std.process.argsAlloc(allocator)
if err != nil {
return nil, err
}
Zig version is definitely more concise. But what if you want to handle the error first? Similar to exception handling in other languages, Zig uses the catch
keyword to intercept errors instead of propagating the error up the call stack.
// notice here, our main function can't return an error,
// otherwise its return type would be !void
pub fn main() void {
const args_count = get_args_count(allocator) catch |err| {
// do some stuff, maybe log an error
std.debug.print("invalid input: {}\n", .{err});
return;
};
}
By the way, this can be nicely paired with a fallback return using the following 2 forms. Simple fallback value:
const args_count = get_args_count(allocator) catch 0;
Or by using Zig's named blocks:
const args_count = get_args_count(allocator) catch blk: {
// do some stuff, maybe log an error
// and then return a result
break :blk 0;
};
And lastly, we can use if-else-switch
construct to handle potential errors more precisely:
if (get_args_count(allocator)) |args_count| {
std.debug.print("got {d} args\n", .{args_count});
} else |err| switch (err) {
error.EmptyArgs => {
std.debug.print("invalid input: no args\n", .{});
},
else => {
std.debug.print("unexpected error: {}\n", .{err});
},
}
Remember I mentioned that in Zig selectively omitting error handling is not allowed? That's true, but sometimes you want skip error handling in places where it's not really necessary, for example tests or build code, or some quick scripts. For that you can use catch unreachable
in functions that do not return an error, but that could possibly panic.
pub fn main() void {
const args_count = get_args_count(allocator) catch unreachable;
std.debug.print("got {d} args\n", .{args_count});
}
Zig errors and context
At the end of the day, Zig errors are just enum values and don't contain much information or behaviour, however they do carry (not in the enum itself) a debugging trace which is in theory is accessible at runtime with errorReturnTrace.
Contrary to Zig (as we've seen in Go section), in Go, errors are interfaces, allowing them to be concrete types that can carry additional context.
For instance, when dealing with file operations, a Go function might return an error of type os.PathError. This type includes fields like Op
(the operation), Path
(the file path) and the underlying system error. And this is very helpful. There was an open issue in Zig to allow for something like that, but it didn't happen and developers can use tagged unions instead to create generic results.
Conclusion
There are different approaches to handle errors and every language does it differently. I personally find that both Go and Zig have the best error handling compared to other languages, that's the reason why I wanted to write this article and give you a tiny taste of Zig's error handling.
Both Go and Zig treat errors as values, emphasizing explicit handling. Zig's approach, with its specialized enum, try, and catch keywords, presents a feature-rich and concise way to manage errors.
While Go's error-as-value using interfaces is straightforward and allows for rich contextual errors (like os.PathError
), it can often lead to more verbose code due to explicit if err != nil
checks. Despite this verbosity, Go's simplicity makes it easy to grasp.
Let me know in the comments what you think about error handling in these 2 programming languages.