Skip to main content

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;
}
const value = riskyFunction() catch 0;

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.