Errors & Error Unions

There are no exceptions in Zig — no hidden jumps that can abandon your statement mid-way. Failure is a value: a function either returns a result or an error, the type says which, and every recovery is written in plain sight with try and catch.

Error Sets

An error set enumerates the failures a function can produce. Each error is a value with a name and a compile-time identity; sets can be merged with the || operator.

Declaring Sets

const FileError = error{
    NotFound,
    AccessDenied,
    DiskFull,
};
// a function that may fail declares WHICH errors — visible in the type
fn openConfig() FileError![]const u8 {
    return error.NotFound;
}

The Global Error Set

anyerror is the set of all errors. It is convenient for prototypes and interfaces, but it hides information; production code prefers narrow, explicit sets. Casting a concrete error into anyerror is automatic; casting back is not.

Error Unions

An error union is written Set!T (or simply !T with an inferred set): either a value of type T or one of the declared errors. It is the return type that makes every failure visible.

error union value layout

Figure 1 — an error union is a tagged value: payload or error, never both.

Returning Errors

fn divide(a: u32, b: u32) !u32 {
    if (b == 0) return error.DivisionByZero; // early, visible failure
    return a / b;
}

try — Propagate

try unwraps the error union: on success the expression IS the payload; on failure it returns the error from the current function immediately. One character, zero hidden control flow — the jump is explicit in the keyword.

pub fn main() !void {
    const q = try divide(10, 2); // 5, or main returns the error
    std.debug.print("{d}\n", .{q});
}

catch — Recover

catch handles failure locally. With a value on the right side it provides a fallback; with |err| it captures the error for inspection.

const r = divide(1, 0) catch 0; // 0 on failure — fallback strategy
const s = divide(1, 0) catch |err| blk: {
    std.debug.print("failed: {s}\n", .{@errorName(err)});
    break :blk 0;
};

errdefer — Cleanup on Error

Paired with errors, errdefer guarantees resources are released exactly when a function unwinds with failure, while defer handles the happy path. Together they replace both goto cleanup and exception destructors.

Basics

fn process() ![]u8 {
    const buf = try std.heap.page_allocator.alloc(u8, 128);
    errdefer std.heap.page_allocator.free(buf); // only on error
    const data = try readAll();                  // failure -> errdefer fires
    return buf;                                  // success -> caller frees
}

Error Return Traces

In safety-enabled builds, an uncaught error prints an error return trace: the chain of trys that carried the failure, each with file and line. Unlike a crash, this is a first-class diagnostic channel.

Reading a Return Trace

// zig run this file: the runtime reports the exact call chain
pub fn main() !void {
    const value = try readConfig(); // reads env, then file
    std.debug.print("{s}\n", .{value});
}
// error: ConfigMissing ... readConfig at src/main.zig:3
//       load at src/main.zig:8
Thinking in Zig: exceptions interrupt your flow and hide in signatures; Zig errors are data you move around. The mental shift is small but mighty: a function that can fail looks different from one that cannot, and reviewers see failure handling at a glance.

Common Pitfalls

Overusing anyerror

anyerror is one of the few genuinely unsafe choices: it hides which failures are possible. Start narrow, widen only when a public API truly spans unknown error spaces.

Swallowing Errors

catch with a silent fallback hides real trouble. When you cannot handle an error properly, propagate it with try — a loud failure beats a quiet wrong answer.

Next: Allocators & Memory — the heart of thinking in Zig.