# Errors

# Error Set Type

An error set is like an enum. However, each error name across the entire compilation gets assigned an unsigned integer greater than 0. You are allowed to declare the same error name more than once, and if you do, it gets assigned the same integer value.

The error set type defaults to a u16, though if the maximum number of distinct error values is provided via the --error-limit [num] command line parameter an integer type with the minimum number of bits required to represent all of the error values will be used.

You can coerce an error from a subset to a superset:

test_coerce_error_subset_to_superset.zig
const std = @import("std");

const FileOpenError = error{
    AccessDenied,
    OutOfMemory,
    FileNotFound,
};

const AllocationError = error{
    OutOfMemory,
};

test "coerce subset to superset" {
    const err = foo(AllocationError.OutOfMemory);
    try std.testing.expect(err == FileOpenError.OutOfMemory);
}

fn foo(err: AllocationError) FileOpenError {
    return err;
}

Shell

$ zig test test_coerce_error_subset_to_superset.zig
1/1 test_coerce_error_subset_to_superset.test.coerce subset to superset...OK
All 1 tests passed.

But you cannot coerce an error from a superset to a subset:

test_coerce_error_superset_to_subset.zig
const FileOpenError = error{
    AccessDenied,
    OutOfMemory,
    FileNotFound,
};

const AllocationError = error{
    OutOfMemory,
};

test "coerce superset to subset" {
    foo(FileOpenError.OutOfMemory) catch {};
}

fn foo(err: FileOpenError) AllocationError {
    return err;
}

Shell

$ zig test test_coerce_error_superset_to_subset.zig
/home/andy/dev/zig/doc/langref/test_coerce_error_superset_to_subset.zig:16:12: error: expected type 'error{OutOfMemory}', found 'error{AccessDenied,FileNotFound,OutOfMemory}'
    return err;
           ^~~
/home/andy/dev/zig/doc/langref/test_coerce_error_superset_to_subset.zig:16:12: note: 'error.AccessDenied' not a member of destination error set
/home/andy/dev/zig/doc/langref/test_coerce_error_superset_to_subset.zig:16:12: note: 'error.FileNotFound' not a member of destination error set
/home/andy/dev/zig/doc/langref/test_coerce_error_superset_to_subset.zig:15:28: note: function return type declared here
fn foo(err: FileOpenError) AllocationError {
                           ^~~~~~~~~~~~~~~
referenced by:
    test.coerce superset to subset: /home/andy/dev/zig/doc/langref/test_coerce_error_superset_to_subset.zig:12:8

There is a shortcut for declaring an error set with only 1 value, and then getting that value:

single_value_error_set_shortcut.zig
const err = error.FileNotFound;

This is equivalent to:

single_value_error_set.zig
const err = (error{FileNotFound}).FileNotFound;

This becomes useful when using Inferred Error Sets.

# The Global Error Set

anyerror refers to the global error set. This is the error set that contains all errors in the entire compilation unit, i.e. it is the union of all other error sets.

You can coerce any error set to the global one, and you can explicitly cast an error of the global error set to a non-global one. This inserts a language-level assert to make sure the error value is in fact in the destination error set.

The global error set should generally be avoided because it prevents the compiler from knowing what errors are possible at compile-time. Knowing the error set at compile-time is better for generated documentation and helpful error messages, such as forgetting a possible error value in a switch.

# Error Union Type

An error set type and normal type can be combined with the ! binary operator to form an error union type. You are likely to use an error union type more often than an error set type by itself.

Here is a function to parse a string into a 64-bit integer:

error_union_parsing_u64.zig
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);
}

Shell

$ zig test error_union_parsing_u64.zig
1/1 error_union_parsing_u64.test.parse u64...OK
All 1 tests passed.

Notice the return type is !u64. This means that the function either returns an unsigned 64 bit integer, or an error. We left off the error set to the left of the !, so the error set is inferred.

Within the function definition, you can see some return statements that return an error, and at the bottom a return statement that returns a u64. Both types coerce to anyerror!u64.

What it looks like to use this function varies depending on what you're trying to do. One of the following:

  • You want to provide a default value if it returned an error.
  • If it returned an error then you want to return the same error.
  • You know with complete certainty it will not return an error, so want to unconditionally unwrap it.
  • You want to take a different action for each possible error.

# catch

If you want to provide a default value, you can use the catch binary operator:

catch.zig
const parseU64 = @import("error_union_parsing_u64.zig").parseU64;

fn doAThing(str: []u8) void {
    const number = parseU64(str, 10) catch 13;
    _ = number; // ...
}

In this code, number will be equal to the successfully parsed string, or a default value of 13. The type of the right hand side of the binary catch operator must match the unwrapped error union type, or be of type noreturn.

If you want to provide a default value with catch after performing some logic, you can combine catch with named Blocks:

handle_error_with_catch_block.zig.zig
const parseU64 = @import("error_union_parsing_u64.zig").parseU64;

fn doAThing(str: []u8) void {
    const number = parseU64(str, 10) catch blk: {
        // do things
        break :blk 13;
    };
    _ = number; // number is now initialized
}

# try

Let's say you wanted to return the error if you got one, otherwise continue with the function logic:

catch_err_return.zig
const parseU64 = @import("error_union_parsing_u64.zig").parseU64;

fn doAThing(str: []u8) !void {
    const number = parseU64(str, 10) catch |err| return err;
    _ = number; // ...
}

There is a shortcut for this. The try expression:

try.zig
const parseU64 = @import("error_union_parsing_u64.zig").parseU64;

fn doAThing(str: []u8) !void {
    const number = try parseU64(str, 10);
    _ = number; // ...
}

try evaluates an error union expression. If it is an error, it returns from the current function with the same error. Otherwise, the expression results in the unwrapped value.

Maybe you know with complete certainty that an expression will never be an error. In this case you can do this:

const number = parseU64("1234", 10) catch unreachable;

Here we know for sure that "1234" will parse successfully. So we put the unreachable value on the right hand side. unreachable invokes safety-checked Illegal Behavior, so in Debug and ReleaseSafe, triggers a safety panic by default. So, while we're debugging the application, if there was a surprise error here, the application would crash appropriately.

You may want to take a different action for every situation. For that, we combine the if and switch expression:

handle_all_error_scenarios.zig
fn doAThing(str: []u8) void {
    if (parseU64(str, 10)) |number| {
        doSomethingWithNumber(number);
    } else |err| switch (err) {
        error.Overflow => {
            // handle overflow...
        },
        // we promise that InvalidChar won't happen (or crash in debug mode if it does)
        error.InvalidChar => unreachable,
    }
}

Finally, you may want to handle only some errors. For that, you can capture the unhandled errors in the else case, which now contains a narrower error set:

handle_some_error_scenarios.zig
fn doAnotherThing(str: []u8) error{InvalidChar}!void {
    if (parseU64(str, 10)) |number| {
        doSomethingWithNumber(number);
    } else |err| switch (err) {
        error.Overflow => {
            // handle overflow...
        },
        else => |leftover_err| return leftover_err,
    }
}

You must use the variable capture syntax. If you don't need the variable, you can capture with _ and avoid the switch.

handle_no_error_scenarios.zig
fn doADifferentThing(str: []u8) void {
    if (parseU64(str, 10)) |number| {
        doSomethingWithNumber(number);
    } else |_| {
        // do as you'd like
    }
}

# errdefer

The other component to error handling is defer statements. In addition to an unconditional defer, Zig has errdefer, which evaluates the deferred expression on block exit path if and only if the function returned with an error from the block.

Example:

errdefer_example.zig
fn createFoo(param: i32) !Foo {
    const foo = try tryToAllocateFoo();
    // now we have allocated foo. we need to free it if the function fails.
    // but we want to return it if the function succeeds.
    errdefer deallocateFoo(foo);

    const tmp_buf = allocateTmpBuffer() orelse return error.OutOfMemory;
    // tmp_buf is truly a temporary resource, and we for sure want to clean it up
    // before this block leaves scope
    defer deallocateTmpBuffer(tmp_buf);

    if (param > 1337) return error.InvalidParam;

    // here the errdefer will not run since we're returning success from the function.
    // but the defer will run!
    return foo;
}

The neat thing about this is that you get robust error handling without the verbosity and cognitive overhead of trying to make sure every exit path is covered. The deallocation code is always directly following the allocation code.

The errdefer statement can optionally capture the error:

test_errdefer_capture.zig
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);
    }
}

Shell

$ zig test test_errdefer_capture.zig
1/1 test_errdefer_capture.test.errdefer capture...OK
All 1 tests passed.

A couple of other tidbits about error handling:

  • These primitives give enough expressiveness that it's completely practical to have failing to check for an error be a compile error. If you really want to ignore the error, you can add catch unreachable and get the added benefit of crashing in Debug and ReleaseSafe modes if your assumption was wrong.

  • Since Zig understands error types, it can pre-weight branches in favor of errors not occurring. Just a small optimization benefit that is not available in other languages.

See also:

An error union is created with the ! binary operator. You can use compile-time reflection to access the child type of an error union:

test_error_union.zig
const expect = @import("std").testing.expect;

test "error union" {
    var foo: anyerror!i32 = undefined;

    // Coerce from child type of an error union:
    foo = 1234;

    // Coerce from an error set:
    foo = error.SomeError;

    // Use compile-time reflection to access the payload type of an error union:
    try comptime expect(@typeInfo(@TypeOf(foo)).error_union.payload == i32);

    // Use compile-time reflection to access the error set type of an error union:
    try comptime expect(@typeInfo(@TypeOf(foo)).error_union.error_set == anyerror);
}

Shell

$ zig test test_error_union.zig
1/1 test_error_union.test.error union...OK
All 1 tests passed.

# Merging Error Sets

Use the || operator to merge two error sets together. The resulting error set contains the errors of both error sets. Doc comments from the left-hand side override doc comments from the right-hand side. In this example, the doc comments for C.PathNotFound is A doc comment.

This is especially useful for functions which return different error sets depending on comptime branches. For example, the Zig standard library uses LinuxFileOpenError || WindowsFileOpenError for the error set of opening files.

test_merging_error_sets.zig
const A = error{
    NotDir,

    /// A doc comment
    PathNotFound,
};
const B = error{
    OutOfMemory,

    /// B doc comment
    PathNotFound,
};

const C = A || B;

fn foo() C!void {
    return error.NotDir;
}

test "merge error sets" {
    if (foo()) {
        @panic("unexpected");
    } else |err| switch (err) {
        error.OutOfMemory => @panic("unexpected"),
        error.PathNotFound => @panic("unexpected"),
        error.NotDir => {},
    }
}

Shell

$ zig test test_merging_error_sets.zig
1/1 test_merging_error_sets.test.merge error sets...OK
All 1 tests passed.

# Inferred Error Sets

Because many functions in Zig return a possible error, Zig supports inferring the error set. To infer the error set for a function, prepend the ! operator to the function’s return type, like !T:

test_inferred_error_sets.zig
// With an inferred error set
pub fn add_inferred(comptime T: type, a: T, b: T) !T {
    const ov = @addWithOverflow(a, b);
    if (ov[1] != 0) return error.Overflow;
    return ov[0];
}

// With an explicit error set
pub fn add_explicit(comptime T: type, a: T, b: T) Error!T {
    const ov = @addWithOverflow(a, b);
    if (ov[1] != 0) return error.Overflow;
    return ov[0];
}

const Error = error{
    Overflow,
};

const std = @import("std");

test "inferred error set" {
    if (add_inferred(u8, 255, 1)) |_| unreachable else |err| switch (err) {
        error.Overflow => {}, // ok
    }
}

Shell

$ zig test test_inferred_error_sets.zig
1/1 test_inferred_error_sets.test.inferred error set...OK
All 1 tests passed.

When a function has an inferred error set, that function becomes generic and thus it becomes trickier to do certain things with it, such as obtain a function pointer, or have an error set that is consistent across different build targets. Additionally, inferred error sets are incompatible with recursion.

In these situations, it is recommended to use an explicit error set. You can generally start with an empty error set and let compile errors guide you toward completing the set.

These limitations may be overcome in a future version of Zig.

# Error Return Traces

Error Return Traces show all the points in the code that an error was returned to the calling function. This makes it practical to use try everywhere and then still be able to know what happened if an error ends up bubbling all the way out of your application.

error_return_trace.zig
pub fn main() !void {
    try foo(12);
}

fn foo(x: i32) !void {
    if (x >= 5) {
        try bar();
    } else {
        try bang2();
    }
}

fn bar() !void {
    if (baz()) {
        try quux();
    } else |err| switch (err) {
        error.FileNotFound => try hello(),
    }
}

fn baz() !void {
    try bang1();
}

fn quux() !void {
    try bang2();
}

fn hello() !void {
    try bang2();
}

fn bang1() !void {
    return error.FileNotFound;
}

fn bang2() !void {
    return error.PermissionDenied;
}

Shell

$ zig build-exe error_return_trace.zig
$ ./error_return_trace
error: PermissionDenied
/home/andy/dev/zig/doc/langref/error_return_trace.zig:34:5: 0x113d36c in bang1 (error_return_trace.zig)
    return error.FileNotFound;
    ^
/home/andy/dev/zig/doc/langref/error_return_trace.zig:22:5: 0x113d3b6 in baz (error_return_trace.zig)
    try bang1();
    ^
/home/andy/dev/zig/doc/langref/error_return_trace.zig:38:5: 0x113d3ec in bang2 (error_return_trace.zig)
    return error.PermissionDenied;
    ^
/home/andy/dev/zig/doc/langref/error_return_trace.zig:30:5: 0x113d496 in hello (error_return_trace.zig)
    try bang2();
    ^
/home/andy/dev/zig/doc/langref/error_return_trace.zig:17:31: 0x113d56e in bar (error_return_trace.zig)
        error.FileNotFound => try hello(),
                              ^
/home/andy/dev/zig/doc/langref/error_return_trace.zig:7:9: 0x113d654 in foo (error_return_trace.zig)
        try bar();
        ^
/home/andy/dev/zig/doc/langref/error_return_trace.zig:2:5: 0x113d71b in main (error_return_trace.zig)
    try foo(12);
    ^

Look closely at this example. This is no stack trace.

You can see that the final error bubbled up was PermissionDenied, but the original error that started this whole thing was FileNotFound. In the bar function, the code handles the original error code, and then returns another one, from the switch statement. Error Return Traces make this clear, whereas a stack trace would look like this:

stack_trace.zig
pub fn main() void {
    foo(12);
}

fn foo(x: i32) void {
    if (x >= 5) {
        bar();
    } else {
        bang2();
    }
}

fn bar() void {
    if (baz()) {
        quux();
    } else {
        hello();
    }
}

fn baz() bool {
    return bang1();
}

fn quux() void {
    bang2();
}

fn hello() void {
    bang2();
}

fn bang1() bool {
    return false;
}

fn bang2() void {
    @panic("PermissionDenied");
}

Shell

$ zig build-exe stack_trace.zig
$ ./stack_trace
thread 2902479 panic: PermissionDenied
/home/andy/dev/zig/doc/langref/stack_trace.zig:38:5: 0x1140e6c in bang2 (stack_trace.zig)
    @panic("PermissionDenied");
    ^
/home/andy/dev/zig/doc/langref/stack_trace.zig:30:10: 0x11414ac in hello (stack_trace.zig)
    bang2();
         ^
/home/andy/dev/zig/doc/langref/stack_trace.zig:17:14: 0x1140e23 in bar (stack_trace.zig)
        hello();
             ^
/home/andy/dev/zig/doc/langref/stack_trace.zig:7:12: 0x1140ab8 in foo (stack_trace.zig)
        bar();
           ^
/home/andy/dev/zig/doc/langref/stack_trace.zig:2:8: 0x113f871 in main (stack_trace.zig)
    foo(12);
       ^
/home/andy/dev/zig/lib/std/start.zig:618:22: 0x113eabd in posixCallMainAndExit (std.zig)
            root.main();
                     ^
/home/andy/dev/zig/lib/std/start.zig:232:5: 0x113e351 in _start (std.zig)
    asm volatile (switch (native_arch) {
    ^
???:?:?: 0x0 in ??? (???)
(process terminated by signal)

Here, the stack trace does not explain how the control flow in bar got to the hello() call. One would have to open a debugger or further instrument the application in order to find out. The error return trace, on the other hand, shows exactly how the error bubbled up.

This debugging feature makes it easier to iterate quickly on code that robustly handles all error conditions. This means that Zig developers will naturally find themselves writing correct, robust code in order to increase their development pace.

Error Return Traces are enabled by default in Debug builds and disabled by default in ReleaseFast, ReleaseSafe and ReleaseSmall builds.

There are a few ways to activate this error return tracing feature:

  • Return an error from main
  • An error makes its way to catch unreachable and you have not overridden the default panic handler
  • Use @errorReturnTrace to access the current return trace. You can use std.debug.dumpStackTrace to print it. This function returns comptime-known null when building without error return tracing support.

# Implementation Details

To analyze performance cost, there are two cases:

  • when no errors are returned
  • when returning errors

For the case when no errors are returned, the cost is a single memory write operation, only in the first non-failable function in the call graph that calls a failable function, i.e. when a function returning void calls a function returning error. This is to initialize this struct in the stack memory:

stack_trace_struct.zig
pub const StackTrace = struct {
    index: usize,
    instruction_addresses: [N]usize,
};

Here, N is the maximum function call depth as determined by call graph analysis. Recursion is ignored and counts for 2.

A pointer to StackTrace is passed as a secret parameter to every function that can return an error, but it's always the first parameter, so it can likely sit in a register and stay there.

That's it for the path when no errors occur. It's practically free in terms of performance.

When generating the code for a function that returns an error, just before the return statement (only for the return statements that return errors), Zig generates a call to this function:

zig_return_error_fn.zig
// marked as "no-inline" in LLVM IR
fn __zig_return_error(stack_trace: *StackTrace) void {
    stack_trace.instruction_addresses[stack_trace.index] = @returnAddress();
    stack_trace.index = (stack_trace.index + 1) % N;
}

The cost is 2 math operations plus some memory reads and writes. The memory accessed is constrained and should remain cached for the duration of the error return bubbling.

As for code size cost, 1 function call before a return statement is no big deal. Even so, I have a plan to make the call to __zig_return_error a tail call, which brings the code size cost down to actually zero. What is a return statement in code without error return tracing can become a jump instruction in code with error return tracing.