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:

go
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
func readFile(path string) (string, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        return "", err
    }
    return string(data), nil
}

func main() {
    content, err := readFile("test.txt")
    if err != nil {
        log.Fatal(err)
    }
    fmt.Println(content)
}

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:

rust
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
fn read_file(path: &str) -> Result<String, io::Error> {
    let content = fs::read_to_string(path)?;
    Ok(content)
}

fn main() {
    match read_file("test.txt") {
        Ok(content) => println!("{}", content),
        Err(e) => eprintln!("Error: {}", e),
    }
}

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
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
const FileError = error{
    NotFound,
    PermissionDenied,
};

fn readFile(path: []const u8) ![]const u8 {
    // ...
    return FileError.NotFound;
}

pub fn main() void {
    const content = readFile("test.txt") catch |err| {
        std.debug.print("Error: {}\n", .{err});
        return;
    };
    std.debug.print("{s}\n", .{content});
}

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:

zig
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
const FileError = error{
    NotFound,
    PermissionDenied,
    OutOfMemory,
};

// Error sets can be merged
const IoError = FileError || error{
    BadFormat,
};

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:

zig
1
return error.NotFound; // Just the name, no description string

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:

zig
1
2
3
4
5
6
7
8
fn processFile(path: []const u8) !void {
    const content = readFile(path) catch |err| {
        // Caller adds context
        std.debug.print("Failed to process file {s}: {}\n", .{ path, err });
        return err;
    };
    _ = content;
}

!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>:

AspectRustZig
Type representationResult<T, E> enum!T or SpecificError!T
Error payloadCan carry any typeNo payload, name only
Runtime costEnum tag + payload constructionA single integer (implicit global error index)
Error type parameterRequires explicit genericCompiler infers automatically
Type conversion? via From traitNo automatic conversion, flat error names
zig
1
2
3
4
5
// Explicit specific error set
fn readConfig() FileError![]const u8 { ... }

// Shorthand: any error (recommended for most cases)
fn readConfig() ![]const u8 { ... }

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.

zig
1
2
3
4
5
6
7
fn processFile(path: []const u8) !void {
    // try is equivalent to: mayFail() catch |err| return err;
    const content = try readFile(path);

    // Process content...
    _ = content;
}

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:

zig
1
const content = readFile("test.txt") catch "";

Capture and handle the error:

zig
1
2
3
4
const result = readFile("test.txt") catch |err| {
    std.debug.print("Error: {}\n", .{err});
    return;
};

Fallback with a blk label — execute complex logic in catch and still provide a default value:

zig
1
2
3
4
const value = mayFail() catch |err| blk: {
    // Do some cleanup or logging
    break :blk 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:

zig
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
fn createAndUse(allocator: std.mem.Allocator) !void {
    const buffer = try allocator.alloc(u8, 1024);
    defer allocator.free(buffer); // Always executes

    const resource = try acquireResource();
    errdefer releaseResource(resource); // Only executes on error return

    // Use resources...
    try doSomethingDangerous();

    // Success path: normal release
    releaseResource(resource);
}

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:

OperationOn successOn error
alloc memorydefer free (unconditional)defer free (unconditional)
acquire resourceExplicit releaseerrdefer 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:

go
1
2
3
4
5
6
resource, err := acquireResource()
if err != nil {
    return err
}
defer releaseResource(resource)
// Success path also needs explicit release

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

go
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
func readFile(path string) (string, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        return "", err
    }
    return string(data), nil
}

func main() {
    content, err := readFile("test.txt")
    if err != nil {
        log.Fatal(err)
    }
    fmt.Println(content)
}

Rust

rust
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
fn read_file(path: &str) -> Result<String, io::Error> {
    let content = fs::read_to_string(path)?;
    Ok(content)
}

fn main() {
    match read_file("test.txt") {
        Ok(content) => println!("{}", content),
        Err(e) => eprintln!("Error: {}", e),
    }
}

Zig

zig
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
fn readFile(path: []const u8) ![]const u8 {
    // Simplified example
    _ = path;
    return error.NotFound;
}

pub fn main() void {
    const content = readFile("test.txt") catch |err| {
        std.debug.print("Error: {}\n", .{err});
        return;
    };
    std.debug.print("{s}\n", .{content});
}

Paradigm Summary

DimensionGoRustZig
Return type(T, error) multi-returnResult<T, E> enum!T error union type
Error propagationif err != nil { return }? operatortry
In-place handlingif err != nil { ... }match / unwrap_orcatch / `catch
Error typeerror interfaceGeneric Eerror{...} named set
Error carries dataYes (any value)Yes (generic payload)No (name only)
Cleanup mechanismdeferDrop traitdefer + errdefer
Can be ignoredYes (_)Must handleMust 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.”