Zig Error Handling
Keep it simple
It’s no secret that I love Zig and have been using it a lot lately. One of its special features and quirks is it’s error handling. Zig has a unique approach to error handling that is different from many other programming languages. In this post, I will explain how Zig’s error handling works and why it fits really well into my mental model of programming.
What are Error sets
In Zig errors are represented as a special type called error sets. An error set is basically an enum that can contain multiple error values of possible errors that a function can return. For example, a function that reads a file might return an error set that contains errors for “file not found”, “permission denied”, and “read error”.
Here’s a simple example
const std = @import("std");
const FileError = error{
FileNotFound,
PermissionDenied,
ReadError,
};
// A function that reads a file and returns a byte slice or a FileError
fn readFile(path: []const u8) FileError![]u8 {
// Example: always return an error for demonstration
return FileError.FileNotFound;
}
Simple, clear and to the point. The function readFile
returns a result type that can either be a byte slice (the file contents) or an error from the FileError
set.
But what is that !
in the return type?
The !
symbol in Zig is used to indicate that a function can return an error. Which in this case it does, in fact it’s the only thing it does in the example above.
As long as your enum is defined as an error set it is a valid return type for a function that prefixes it’s return type with !
.
Tip: You can also use !void
as a return type for functions that don’t return any value but can still fail.
Tip #2: You can merge sets with || if your function deals with multiple error sources. We’ll see that in a sec.
Error Unions
Functions can return a value or error. That’s !T
where T
is the return type. This is called an error union.
You can use it like so:
fn readNumber(str: []const u8) !u32 {
if (str.len == 0) return error.EmptyString;
return std.fmt.parseInt(u32, str, 10);
}
const num = try readNumber("123"); // errors bubble up
const bad = readNumber("abc") catch |err| {
std.debug.print("Caught: {}\n", .{err});
return err;
};
In this example, readNumber
tries to parse a string into a number. If the string is empty, it returns an EmptyString
error. If parsing fails, it returns whatever error std.fmt.parseInt
produces.
The try
keyword is used to propagate errors up the call stack. If readNumber
returns an error, it will be returned from the calling function as well.
No hidden stuff, no exceptions, no magic, just plain and simple error handling.
Note: I personally prefer programming languages which treat errors as values. It makes reasoning about code much easier.
What is Bubbling Up Errors ?
Above we briefly touched on bubbling errors up the call stack with try
. This is a common pattern in Zig.
When you use try
, if the function returns an error, that error is immediately returned from the calling function. This allows you to propagate errors up the call stack without having to explicitly handle them at each level.
Here’s an example:
fn deepFunction() !void {
return error.SomethingWentWrong;
}
fn middleFunction() !void {
try deepFunction();
}
pub fn main() void {
middleFunction() catch |err| {
std.debug.print("Error: {}\n", .{err});
};
}
In this example, deepFunction
returns an error. middleFunction
calls deepFunction
using try
, which means if deepFunction
returns an error, that error is immediately returned from middleFunction
. Finally, in main
, we call middleFunction
and handle the error using catch
.
So in summary: Deep fails -> middle propagates -> main handles.
Simple and straightforward.
What are Error Handling Best Practices in Zig?
Here are some best practices for error handling in Zig:
- Use explicit error sets: Define clear and specific error sets for your functions. This makes it easier to understand what errors can occur and how to handle them.
- Name errors descriptively: Use descriptive names for your errors to make it clear what went wrong.
- Handle all cases: When using
catch
, make sure to handle all possible error cases. This ensures that your program can gracefully recover from errors. - Propagate instead of ignoring: Use
try
to propagate errors up the call stack instead of ignoring them. This helps to ensure that errors are not silently ignored and can be handled appropriately. - Use
defer
for cleanup: Usedefer
to ensure that resources are cleaned up properly, even in the presence of errors.
Now these are just some of the guidelines that I adhere to yours may differ and that’s all right.
Real World Example
Let’s look at a more complex example that combines multiple error sources and demonstrates error propagation.
const std = @import("std");
const ProcessError = error{
FileNotFound,
InvalidFormat,
AccessDenied,
} || std.fs.File.OpenError || std.fs.File.ReadError;
fn processDocument(path: []const u8) ProcessError!void {
const file = try std.fs.cwd().openFile(path, .{});
defer file.close();
var buffer: [1024]u8 = undefined;
const size = try file.readAll(&buffer);
if (size < 10) return ProcessError.InvalidFormat;
std.debug.print("Processed {} bytes from {}\n", .{size, path});
}
pub fn main() void {
const files = [_][]const u8{"doc1.txt", "doc2.txt"};
for (files) |file| {
processDocument(file) catch |err| {
switch(err) {
ProcessError.FileNotFound => continue,
ProcessError.InvalidFormat => continue,
ProcessError.AccessDenied => continue,
else => continue,
}
};
}
}
In this example, processDocument
attempts to open and read a file. It can return
FileNotFound
if the file doesn’t exist,InvalidFormat
if the file content is not as expected,AccessDenied
if there are permission issues,- or any errors from
std.fs.File.OpenError
andstd.fs.File.ReadError
. Inmain
, we attempt to process multiple documents, handling errors appropriately based on their type.
We use try
to propagate errors from file operations and custom checks, and catch
in main
to handle them.
After we use defer
to ensure the file is closed properly, even if an error occurs during reading.
Conclusion
Zig’s error handling model is simple, explicit, and powerful. By treating errors as values and using constructs like try
and catch
, Zig allows developers to write clear and maintainable code that gracefully handles errors. Whether you’re writing low-level system code or high-level application logic, Zig’s error handling model provides the tools you need to manage errors effectively.
I hope this post has given you a good understanding of how error handling works in Zig and why it’s a great fit for many programming scenarios.