Error Handling: A Third Way Beyond Go and Rust
This article is based on Zig 0.16.
Three Error Handling Paradigms
Error handling is one of the most debated topics in programming language design. Go’s multiple return values, Rust’s Result<T, E> enum, and Zig’s error union type represent three fundamentally different philosophies. This article assumes you have Go or Rust experience and uses that as a reference frame to understand Zig’s design.
Go: Multi-return + if err != nil
Go’s philosophy is “explicit over implicit” — functions can return multiple values, and the convention is that the last return value is error. Every call site must handle it:
| |
The advantage is you always know errors are there. The downside is just as obvious: _ (blank identifier) lets errors be silently ignored — a long-standing debate in the Go community. Moreover, every fallible call requires three lines of if err != nil, leading to rapid code bloat.
Rust: Result Enum + ? Operator
Rust elevates errors into the type system. A function that can fail must declare it in its return type:
| |
Result<T, E> is an enum, and the ? operator returns early on Err while performing automatic error type conversion via the From trait. Compared to Go, Rust’s advantage is that errors cannot be ignored — you must decide how to handle them. The tradeoff is that Result carries a payload, so every Err involves the overhead of an enum tag plus generic payload construction.
Zig: Error Union Type
Zig chose a third path with the error union type !T, meaning “either a value of type T, or an error”:
| |
Zig has neither if err != nil nor match — instead it uses try and catch to handle errors. Crucially, Zig’s errors have no payload — an error is just a name with no descriptive information. This is an intentional zero-cost design.
Error Set
Zig uses error{...} to define a set of possible errors, called an error set:
| |
Each error is just a name with no attached data. This is fundamentally different from Go’s error interface (which can carry any value) or Rust’s Result<T, E> (where E can be any type). Zig’s error propagation is a simple integer comparison — no heap allocation, interface boxing, or string copying:
| |
What if you need detailed error information? Zig’s philosophy is to pass additional context through out-of-band channels. The most common pattern is to wrap context at the call site:
| |
!T: Error Union Type
!T is syntactic sugar equivalent to anyerror!T, the union of the global error supertype and T. This type holds either an error value or a value of type T.
Core differences compared to Rust’s Result<T, E>:
| Aspect | Rust | Zig |
|---|---|---|
| Type representation | Result<T, E> enum | !T or SpecificError!T |
| Error payload | Can carry any type | No payload, name only |
| Runtime cost | Enum tag + payload construction | A single integer (implicit global error index) |
| Error type parameter | Requires explicit generic | Compiler infers automatically |
| Type conversion | ? via From trait | No automatic conversion, flat error names |
| |
Using !T (the global error supertype) over a qualified SpecificError!T is recommended for public APIs — the underlying implementation may introduce new error types, and the global supertype is more flexible.
try: Fail Fast
try is the most frequently used error handling keyword in Zig. It is equivalent to Rust’s ? operator — if the result is an error, the function immediately returns that error; otherwise, it unwraps the normal value.
| |
A single try accomplishes what takes three lines in Go (if err != nil { return err }). Unlike Rust’s ?, try performs no automatic error type conversion — it simply “returns on error” with no hidden semantics.
Note: try can only be used in functions that return !T. Using try in a void function triggers a compile error.
catch: Handle In Place
catch is the alternative to try, letting you handle errors in place rather than propagating them. There are three forms:
Provide a default value:
| |
Capture and handle the error:
| |
Fallback with a blk label — execute complex logic in catch and still provide a default value:
| |
blk is a block expression; break :blk returns the subsequent value as the result of the entire catch expression. This is more concise than Rust’s unwrap_or_else(|| { ... }) and Go’s if err != nil { return default }.
errdefer: Execute Only on Error
errdefer is the most distinctive feature in Zig’s error handling. Like defer, it executes at the end of the scope, but only triggers when the function returns an error:
| |
This pattern solves a very practical resource management problem: when a function acquires multiple resources along the way, if a later operation fails, previously acquired resources need to be rolled back.
Typical pairing pattern:
| Operation | On success | On error |
|---|---|---|
alloc memory | defer free (unconditional) | defer free (unconditional) |
acquire resource | Explicit release | errdefer release (rollback only) |
Comparison with Go: Go only has defer, no errdefer. You need to manually check errors at every failure point and roll back:
| |
Zig’s errdefer makes “cleanup on failure” semantically cleaner — success and failure paths are completely separated, eliminating manual rollback logic in error branches.
Multi-Language Side-by-Side
Let’s compare a real file-reading example across all three languages:
Go
| |
Rust
| |
Zig
| |
Paradigm Summary
| Dimension | Go | Rust | Zig |
|---|---|---|---|
| Return type | (T, error) multi-return | Result<T, E> enum | !T error union type |
| Error propagation | if err != nil { return } | ? operator | try |
| In-place handling | if err != nil { ... } | match / unwrap_or | catch / `catch |
| Error type | error interface | Generic E | error{...} named set |
| Error carries data | Yes (any value) | Yes (generic payload) | No (name only) |
| Cleanup mechanism | defer | Drop trait | defer + errdefer |
| Can be ignored | Yes (_) | Must handle | Must handle |
Zig’s most distinctive trait is no payload. Go’s error interface can carry arbitrary data; Rust’s Err branch can contain any type. Zig chooses to make error propagation zero-cost — it is just a globally unique integer index. When detailed context is needed, it must be passed explicitly through logging or other channels. This embodies the zero-cost abstraction philosophy: you don’t pay for features you don’t need.
Coming Next
The next article explores Zig’s memory management — why Zig says “we don’t build memory management into the language, we just provide a good allocator.”