5 March 2025
One of Zig’s big improvements over C is that it has good support for error handling built into the language. The basic idea is simple: you define error values using the error
keyword, and functions that can fail explicitly return an error value instead of their normal return type.
You can define a set of errors with the error
keyword:
const MathError = error {
DivisionByZero,
IntegerOverflow,
};
and use the !
operator to indicate that a function may return those errors:
fn divide(a: i8, b: i8) MathError!i8 {
if (b == 0) return MathError.DivisionByZero;
if (a == -128 and b == -1) return MathError.IntegerOverflow;
return @divTrunc(a, b);
}
MathError!i8
is called an error union. We could also write the function like this (bold code is what’s changed):
fn divide(a: i8, b: i8) !i8 {
if (b == 0) return error.DivisionByZero;
if (a == -128 and b == -1) return error.IntegerOverflow;
return @divTrunc(a, b);
}
With !i8
as the return type, the compiler will infer the error union by collecting all error values used in the function. The error.DivisionByZero
shorthand creates an error value without first defining an error set.
You might be wondering now: what is an error value, really? In the source code, an error is identified only by its name. That means you can define the same error multiple times. In the following code, e1
, e2
, and e3
are all identical:
const MyError = error{ FileNotFound, PermissionDenied };
const e1 = MyError.FileNotFound;
const YourError = error{ FileNotFound, FilenameInvalid };
const e2 = YourError.FileNotFound;
const e3 = error.FileNotFound;
When the compiler builds your program, it collects all error values and assigns each a number. At runtime, the error values are just integers.
You can use an if
to distinguish successful return from the error case:
if (divide(x, y)) |n| {
std.debug.print("{} / {} = {}\n", .{x, y, n});
} else |err| {
// handle error
}
A similar syntax exists for while
loops:
while (f()) |value| {
// use value
} else |err| {
// handle error
}
When f
returns an error, the else
branch is evaluated and the loop ends.
It’s more common to use the catch
and try
keywords, though. You can use catch
to substitute a default value:
const n = divide(x, y) catch 0;
// the type of n is i8; it'll be 0 in case of error
or run some code if there was an error:
const n = divide(x, y) catch {
std.process.exit(1);
};
The try
keyword is for the common case where you just want to return errors to your caller:
const n = try divide(x, y);
is equivalent to
const n = divide(x, y) catch |err| return err;
If you’re sure won’t be an error you can assert that with unreachable
:
const n = divide(4, 2) catch unreachable;
If there is an error after all, this will cause a panic (when compiled with Debug and ReleaseSafe) or undefined behavior (with ReleaseFast and ReleaseSmall). Incidentally, that’s also what happens if we evaluate @divTrunc(a, b)
without checking the numbers and there’s actually a division by zero or an integer overflow.
If you return an error from the main function, the program will print the error and exit with a non-zero exit status. For example, this code
pub fn main() !void {
std.debug.print("{}\n", .{try divide(-128, -1)});
}
will print
error: IntegerOverflow
In Debug mode, it also prints a trace of where the error came from:
errors$ zig build-exe -O Debug overflow.zig
errors$ ./overflow
error: IntegerOverflow
/home/levin/code/experiment/zig/errors/overflow.zig:5:32: 0x1034e33 in divide (overflow)
if (a == -128 and b == -1) return error.IntegerOverflow;
^
/home/levin/code/experiment/zig/errors/overflow.zig:10:31: 0x1035049 in main (overflow)
std.debug.print("{}\n", .{try divide(-128, -1)});
^