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)});
^