Error Sets
Zig uses explicit error sets instead of exceptions:
const FileOpenError = error{
AccessDenied,
OutOfMemory,
FileNotFound,
};
Error sets are types that can be merged and used with error unions.
Error Unions
Error unions combine a normal type with an error set using !:
const std = @import("std");
const maxInt = std.math.maxInt;
pub fn parseU64(buf: []const u8, radix: u8) !u64 {
var x: u64 = 0;
for (buf) |c| {
const digit = charToDigit(c);
if (digit >= radix) {
return error.InvalidChar;
}
// x *= radix
var ov = @mulWithOverflow(x, radix);
if (ov[1] != 0) return error.OverFlow;
// x += digit
ov = @addWithOverflow(ov[0], digit);
if (ov[1] != 0) return error.OverFlow;
x = ov[0];
}
return x;
}
fn charToDigit(c: u8) u8 {
return switch (c) {
'0'...'9' => c - '0',
'A'...'Z' => c - 'A' + 10,
'a'...'z' => c - 'a' + 10,
else => maxInt(u8),
};
}
test "parse u64" {
const result = try parseU64("1234", 10);
try std.testing.expect(result == 1234);
}
The ! in a return type means the function returns an error union. !T is shorthand for anyerror!T.
Catching Errors
Using catch
The catch keyword provides a default value when an error occurs:
const parseU64 = @import("error_union_parsing_u64.zig").parseU64;
fn doAThing(str: []u8) void {
const number = parseU64(str, 10) catch 13;
_ = number; // ...
}
Using catch with Error Capture
fn handleError(str: []const u8) void {
const number = parseU64(str, 10) catch |err| {
std.debug.print("Error: {}\n", .{err});
return;
};
_ = number;
}
Default Value
Error Capture
Unreachable
const value = riskyFunction() catch 0;
const value = riskyFunction() catch |err| {
std.log.err("Failed: {}", .{err});
return err;
};
const value = riskyFunction() catch unreachable;
Using try
The try keyword propagates errors to the caller:
fn wrapper() !u64 {
const result = try parseU64("1234", 10);
return result * 2;
}
try is equivalent to:
const result = parseU64("1234", 10) catch |err| return err;
Functions using try must have a return type that is an error union.
If with Error Unions
You can test and unwrap error unions with if:
test "if error union" {
const a: anyerror!u32 = 0;
if (a) |value| {
try expect(value == 0);
} else |err| {
_ = err;
unreachable;
}
const b: anyerror!u32 = error.BadValue;
if (b) |value| {
_ = value;
unreachable;
} else |err| {
try expect(err == error.BadValue);
}
// Access the value by reference using a pointer capture.
var c: anyerror!u32 = 3;
if (c) |*value| {
value.* = 9;
} else |_| {
unreachable;
}
}
Defer
The defer keyword executes code when leaving the current scope:
const std = @import("std");
const print = std.debug.print;
pub fn main() void {
print("\n", .{});
defer {
print("1 ", .{});
}
defer {
print("2 ", .{});
}
if (false) {
// defers are not run if they are never executed.
defer {
print("3 ", .{});
}
}
}
// Output: 2 1
Defer statements execute in reverse order (LIFO - Last In, First Out).
Errdefer
The errdefer keyword executes code only when returning an error:
const std = @import("std");
fn captureError(captured: *?anyerror) !void {
errdefer |err| {
captured.* = err;
}
return error.GeneralFailure;
}
test "errdefer capture" {
var captured: ?anyerror = null;
if (captureError(&captured)) unreachable else |err| {
try std.testing.expectEqual(error.GeneralFailure, captured.?);
try std.testing.expectEqual(error.GeneralFailure, err);
}
}
Practical Errdefer Example
fn createResource() !*Resource {
const resource = try allocator.create(Resource);
errdefer allocator.destroy(resource);
try resource.init();
errdefer resource.deinit();
try resource.configure();
return resource;
}
errdefer is perfect for cleanup when initialization fails partway through.
Error Return Traces
Zig can provide error return traces in Debug and ReleaseSafe modes:
pub fn main() !void {
try riskyOperation();
}
fn riskyOperation() !void {
return error.Failed;
}
When run, this shows where the error originated and propagated through.
fn operation() !void {
return error.Failed;
}
Error Set Coercion
Error sets can be coerced to supersets:
const FileError = error{ NotFound, AccessDenied };
const AllErrors = error{ NotFound, AccessDenied, OutOfMemory };
fn getFileError() FileError {
return error.NotFound;
}
test "error coercion" {
const err: AllErrors = getFileError(); // OK - subset coerces to superset
try expect(err == error.NotFound);
}
Switch on Errors
You can switch on error values:
fn handleError(err: anyerror) void {
switch (err) {
error.FileNotFound => std.log.err("File not found", .{}),
error.AccessDenied => std.log.err("Access denied", .{}),
else => std.log.err("Unknown error", .{}),
}
}
Best Practices
- Use
try to propagate errors up the call stack
- Use
catch when you can handle the error locally
- Use
errdefer for cleanup on error paths
- Use
defer for cleanup on all exit paths
- Prefer specific error sets over
anyerror for better documentation
- Use error return traces to debug error propagation
Avoiding catch unreachable unless you’re absolutely certain the error cannot occur. It will panic if the error does occur.