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 number of unique error values across the entire compilation should determine the size of the error set type. However right now it is hard coded to be a u16. See #768.

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

test.zig

  1. const std = @import("std");
  2. const FileOpenError = error {
  3. AccessDenied,
  4. OutOfMemory,
  5. FileNotFound,
  6. };
  7. const AllocationError = error {
  8. OutOfMemory,
  9. };
  10. test "coerce subset to superset" {
  11. const err = foo(AllocationError.OutOfMemory);
  12. std.debug.assert(err == FileOpenError.OutOfMemory);
  13. }
  14. fn foo(err: AllocationError) FileOpenError {
  15. return err;
  16. }
  1. $ zig test test.zig
  2. 1/1 test "coerce subset to superset"...OK
  3. All 1 tests passed.

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

test.zig

  1. const FileOpenError = error {
  2. AccessDenied,
  3. OutOfMemory,
  4. FileNotFound,
  5. };
  6. const AllocationError = error {
  7. OutOfMemory,
  8. };
  9. test "coerce superset to subset" {
  10. foo(FileOpenError.OutOfMemory) catch {};
  11. }
  12. fn foo(err: FileOpenError) AllocationError {
  13. return err;
  14. }
  1. $ zig test test.zig
  2. ./docgen_tmp/test.zig:16:12: error: expected type 'AllocationError', found 'FileOpenError'
  3. return err;
  4. ^
  5. ./docgen_tmp/test.zig:2:5: note: 'error.AccessDenied' not a member of destination error set
  6. AccessDenied,
  7. ^
  8. ./docgen_tmp/test.zig:4:5: note: 'error.FileNotFound' not a member of destination error set
  9. FileNotFound,
  10. ^

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

  1. const err = error.FileNotFound;

This is equivalent to:

  1. 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. It is a superset of all other error sets and a subset of none of them.

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:

test.zig

  1. const std = @import("std");
  2. const maxInt = std.math.maxInt;
  3. pub fn parseU64(buf: []const u8, radix: u8) !u64 {
  4. var x: u64 = 0;
  5. for (buf) |c| {
  6. const digit = charToDigit(c);
  7. if (digit >= radix) {
  8. return error.InvalidChar;
  9. }
  10. // x *= radix
  11. if (@mulWithOverflow(u64, x, radix, &x)) {
  12. return error.Overflow;
  13. }
  14. // x += digit
  15. if (@addWithOverflow(u64, x, digit, &x)) {
  16. return error.Overflow;
  17. }
  18. }
  19. return x;
  20. }
  21. fn charToDigit(c: u8) u8 {
  22. return switch (c) {
  23. '0' ... '9' => c - '0',
  24. 'A' ... 'Z' => c - 'A' + 10,
  25. 'a' ... 'z' => c - 'a' + 10,
  26. else => maxInt(u8),
  27. };
  28. }
  29. test "parse u64" {
  30. const result = try parseU64("1234", 10);
  31. std.debug.assert(result == 1234);
  32. }
  1. $ zig test test.zig
  2. 1/1 test "parse u64"...OK
  3. 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:

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

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.

try

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

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

There is a shortcut for this. The try expression:

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

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:

  1. 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 generates a panic in Debug and ReleaseSafe modes and undefined behavior in ReleaseFast mode. So, while we're debugging the application, if there was a surprise error here, the application would crash appropriately.

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

  1. fn doAThing(str: []u8) void {
  2. if (parseU64(str, 10)) |number| {
  3. doSomethingWithNumber(number);
  4. } else |err| switch (err) {
  5. error.Overflow => {
  6. // handle overflow...
  7. },
  8. // we promise that InvalidChar won't happen (or crash in debug mode if it does)
  9. error.InvalidChar => unreachable,
  10. }
  11. }

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:

  1. fn createFoo(param: i32) !Foo {
  2. const foo = try tryToAllocateFoo();
  3. // now we have allocated foo. we need to free it if the function fails.
  4. // but we want to return it if the function succeeds.
  5. errdefer deallocateFoo(foo);
  6. const tmp_buf = allocateTmpBuffer() orelse return error.OutOfMemory;
  7. // tmp_buf is truly a temporary resource, and we for sure want to clean it up
  8. // before this block leaves scope
  9. defer deallocateTmpBuffer(tmp_buf);
  10. if (param > 1337) return error.InvalidParam;
  11. // here the errdefer will not run since we're returning success from the function.
  12. // but the defer will run!
  13. return foo;
  14. }

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.

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.zig

  1. const assert = @import("std").debug.assert;
  2. test "error union" {
  3. var foo: anyerror!i32 = undefined;
  4. // Coerce from child type of an error union:
  5. foo = 1234;
  6. // Coerce from an error set:
  7. foo = error.SomeError;
  8. // Use compile-time reflection to access the payload type of an error union:
  9. comptime assert(@TypeOf(foo).Payload == i32);
  10. // Use compile-time reflection to access the error set type of an error union:
  11. comptime assert(@TypeOf(foo).ErrorSet == anyerror);
  12. }
  1. $ zig test test.zig
  2. 1/1 test "error union"...OK
  3. 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.zig

  1. const A = error{
  2. NotDir,
  3. /// A doc comment
  4. PathNotFound,
  5. };
  6. const B = error{
  7. OutOfMemory,
  8. /// B doc comment
  9. PathNotFound,
  10. };
  11. const C = A || B;
  12. fn foo() C!void {
  13. return error.NotDir;
  14. }
  15. test "merge error sets" {
  16. if (foo()) {
  17. @panic("unexpected");
  18. } else |err| switch (err) {
  19. error.OutOfMemory => @panic("unexpected"),
  20. error.PathNotFound => @panic("unexpected"),
  21. error.NotDir => {},
  22. }
  23. }
  1. $ zig test test.zig
  2. 1/1 test "merge error sets"...OK
  3. 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, use this syntax:

test.zig

  1. // With an inferred error set
  2. pub fn add_inferred(comptime T: type, a: T, b: T) !T {
  3. var answer: T = undefined;
  4. return if (@addWithOverflow(T, a, b, &answer)) error.Overflow else answer;
  5. }
  6. // With an explicit error set
  7. pub fn add_explicit(comptime T: type, a: T, b: T) Error!T {
  8. var answer: T = undefined;
  9. return if (@addWithOverflow(T, a, b, &answer)) error.Overflow else answer;
  10. }
  11. const Error = error {
  12. Overflow,
  13. };
  14. const std = @import("std");
  15. test "inferred error set" {
  16. if (add_inferred(u8, 255, 1)) |_| unreachable else |err| switch (err) {
  17. error.Overflow => {}, // ok
  18. }
  19. }
  1. $ zig test test.zig
  2. 1/1 test "inferred error set"...OK
  3. 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.

test.zig

  1. pub fn main() !void {
  2. try foo(12);
  3. }
  4. fn foo(x: i32) !void {
  5. if (x >= 5) {
  6. try bar();
  7. } else {
  8. try bang2();
  9. }
  10. }
  11. fn bar() !void {
  12. if (baz()) {
  13. try quux();
  14. } else |err| switch (err) {
  15. error.FileNotFound => try hello(),
  16. else => try another(),
  17. }
  18. }
  19. fn baz() !void {
  20. try bang1();
  21. }
  22. fn quux() !void {
  23. try bang2();
  24. }
  25. fn hello() !void {
  26. try bang2();
  27. }
  28. fn another() !void {
  29. try bang1();
  30. }
  31. fn bang1() !void {
  32. return error.FileNotFound;
  33. }
  34. fn bang2() !void {
  35. return error.PermissionDenied;
  36. }
  1. $ zig build-exe test.zig
  2. $ ./test
  3. error: PermissionDenied
  4. /deps/zig/docgen_tmp/test.zig:39:5: 0x22ef02 in bang1 (test)
  5. return error.FileNotFound;
  6. ^
  7. /deps/zig/docgen_tmp/test.zig:23:5: 0x22eddf in baz (test)
  8. try bang1();
  9. ^
  10. /deps/zig/docgen_tmp/test.zig:43:5: 0x22eda2 in bang2 (test)
  11. return error.PermissionDenied;
  12. ^
  13. /deps/zig/docgen_tmp/test.zig:31:5: 0x22eecf in hello (test)
  14. try bang2();
  15. ^
  16. /deps/zig/docgen_tmp/test.zig:17:31: 0x22ed6e in bar (test)
  17. error.FileNotFound => try hello(),
  18. ^
  19. /deps/zig/docgen_tmp/test.zig:7:9: 0x22ec5c in foo (test)
  20. try bar();
  21. ^
  22. /deps/zig/docgen_tmp/test.zig:2:5: 0x22a9e4 in main (test)
  23. try foo(12);
  24. ^

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:

test.zig

  1. pub fn main() void {
  2. foo(12);
  3. }
  4. fn foo(x: i32) void {
  5. if (x >= 5) {
  6. bar();
  7. } else {
  8. bang2();
  9. }
  10. }
  11. fn bar() void {
  12. if (baz()) {
  13. quux();
  14. } else {
  15. hello();
  16. }
  17. }
  18. fn baz() bool {
  19. return bang1();
  20. }
  21. fn quux() void {
  22. bang2();
  23. }
  24. fn hello() void {
  25. bang2();
  26. }
  27. fn bang1() bool {
  28. return false;
  29. }
  30. fn bang2() void {
  31. @panic("PermissionDenied");
  32. }
  1. $ zig build-exe test.zig
  2. $ ./test
  3. PermissionDenied
  4. /deps/zig/docgen_tmp/test.zig:38:5: 0x2302d6 in bang2 (test)
  5. @panic("PermissionDenied");
  6. ^
  7. /deps/zig/docgen_tmp/test.zig:30:10: 0x230a38 in hello (test)
  8. bang2();
  9. ^
  10. /deps/zig/docgen_tmp/test.zig:17:14: 0x2302ba in bar (test)
  11. hello();
  12. ^
  13. /deps/zig/docgen_tmp/test.zig:7:12: 0x22e955 in foo (test)
  14. bar();
  15. ^
  16. /deps/zig/docgen_tmp/test.zig:2:8: 0x22a7ed in main (test)
  17. foo(12);
  18. ^
  19. /deps/zig/lib/std/start.zig:243:22: 0x2046ef in std.start.posixCallMainAndExit (test)
  20. root.main();
  21. ^
  22. /deps/zig/lib/std/start.zig:123:5: 0x2044cf in std.start._start (test)
  23. @call(.{ .modifier = .never_inline }, posixCallMainAndExit, .{});
  24. ^
  25. (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 and ReleaseSafe builds and disabled by default in ReleaseFast 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:

  1. pub const StackTrace = struct {
  2. index: usize,
  3. instruction_addresses: [N]usize,
  4. };

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:

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

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.