Skip to content

Error Union Internals

An error union is a value that can contain either:

An error union is a value that can contain either:

an error

or:

a successful value

You have already seen this shape:

fn readNumber() !i32 {
    return 42;
}

The return type is:

!i32

This means:

either an error, or an i32

You can also write the error set explicitly:

fn readNumber() error{InvalidInput}!i32 {
    return 42;
}

This means:

either error.InvalidInput, or an i32

The short form:

!i32

lets Zig infer the error set.

Error Union as a Type

An error union combines two parts:

ErrorSet!Payload

For example:

error{NotFound}!usize

The error set is:

error{NotFound}

The payload is:

usize

So the full type means:

either error.NotFound, or a usize

This is similar in spirit to an optional type:

?usize

But they mean different things.

An optional says:

maybe a value, maybe null

An error union says:

maybe a value, maybe a named error

null gives no reason. An error gives a reason.

A Simple Example

const std = @import("std");

const ParseError = error{
    Empty,
    InvalidDigit,
};

fn parseOneDigit(text: []const u8) ParseError!u8 {
    if (text.len == 0) {
        return error.Empty;
    }

    const c = text[0];

    if (c < '0' or c > '9') {
        return error.InvalidDigit;
    }

    return c - '0';
}

pub fn main() void {
    const result = parseOneDigit("7");

    if (result) |value| {
        std.debug.print("value: {}\n", .{value});
    } else |err| {
        std.debug.print("error: {}\n", .{err});
    }
}

The function return type is:

ParseError!u8

That means the function can return:

error.Empty
error.InvalidDigit

or a successful u8.

Success and Failure Are One Value

This line does not immediately give you a u8:

const result = parseOneDigit("7");

The type of result is:

ParseError!u8

It is still an error union.

You must unwrap it before using the u8.

if (result) |value| {
    // value is u8 here
} else |err| {
    // err is ParseError here
}

Inside the success block, value is the payload.

Inside the error block, err is the error.

try Unwraps the Success Value

Most Zig code uses try with error unions.

const value = try parseOneDigit("7");

This means:

If parseOneDigit succeeds, put the u8 into value.
If it returns an error, return that error from the current function.

So this:

fn parseTwoDigits(text: []const u8) ParseError!u8 {
    const first = try parseOneDigit(text[0..1]);
    const second = try parseOneDigit(text[1..2]);

    return first * 10 + second;
}

is a compact form of explicit error handling.

The important internal idea is that try does not make the error disappear. It either unwraps the success value or propagates the error.

catch Handles the Error Value

Use catch when you want to handle an error locally.

const value = parseOneDigit("x") catch |err| {
    std.debug.print("could not parse digit: {}\n", .{err});
    return;
};

If parsing succeeds, value is a u8.

If parsing fails, the catch block receives the error.

You can also provide a default value:

const value = parseOneDigit("x") catch 0;

This means:

Use the parsed digit if parsing succeeds.
Use 0 if parsing fails.

Use this only when a default really makes sense.

Error Sets Are Types

This is an error set:

const FileError = error{
    NotFound,
    PermissionDenied,
    TooLarge,
};

It is a type.

Values of this type are written like this:

error.NotFound
error.PermissionDenied
error.TooLarge

A function can return one of those errors:

fn openConfig() FileError!void {
    return error.NotFound;
}

The return type says exactly which errors this function can return.

That is part of the function’s contract.

Inferred Error Sets

You can let Zig infer the error set:

fn openConfig() !void {
    return error.NotFound;
}

Here, Zig can infer that the function may return error.NotFound.

This is convenient, but explicit error sets can be clearer in public APIs.

For internal helper functions, inferred error sets are often fine.

For library functions, explicit error sets often document the API better.

Error Set Coercion

A smaller error set can often be used where a larger error set is expected.

const SmallError = error{
    NotFound,
};

const BigError = error{
    NotFound,
    PermissionDenied,
};

fn small() SmallError!void {
    return error.NotFound;
}

fn big() BigError!void {
    return try small();
}

This works because every SmallError is also part of BigError.

The opposite direction is not safe:

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

fn returnsSmall() SmallError!void {
    return try returnsBig(); // error
}

returnsBig might return error.PermissionDenied, which is not in SmallError.

The compiler protects the declared error contract.

Error Union vs Optional

Use an optional when absence is enough information.

fn findIndex(items: []const u8, target: u8) ?usize {
    for (items, 0..) |item, i| {
        if (item == target) return i;
    }

    return null;
}

If the item is not found, null is enough.

Use an error union when the caller needs to know what went wrong.

const LoadError = error{
    NotFound,
    PermissionDenied,
    InvalidFormat,
};

fn loadConfig(path: []const u8) LoadError!Config {
    // ...
}

Here, failure has different meanings. The caller may handle them differently.

Error Union vs Error Set Alone

These two types are different:

error{NotFound}

and:

error{NotFound}!usize

The first is only an error value.

The second is either an error or a usize.

Example:

const e: error{NotFound} = error.NotFound;

This stores only an error.

const x: error{NotFound}!usize = 10;

This stores a successful usize.

const y: error{NotFound}!usize = error.NotFound;

This stores an error.

Payload Type Can Be void

Many functions can fail but do not return a useful success value.

Their type often looks like this:

!void

or:

error{Failed}!void

This means:

either an error, or successful completion

Example:

fn saveFile() !void {
    // save the file
}

Calling it:

try saveFile();

There is no value to store. Success simply means the function completed.

Error Names Are Global

In Zig, error names are global by name.

If two error sets both contain NotFound, they refer to the same error name.

const FileError = error{
    NotFound,
};

const UserError = error{
    NotFound,
};

Both contain:

error.NotFound

This makes error set coercion possible, but it also means you should choose clear error names.

Sometimes a more specific name is better:

error.FileNotFound
error.UserNotFound

Use names that make sense in the API.

Error Unions Are Not Exceptions

An error union is a normal value.

It is visible in the type.

This function says it can fail:

fn connect() !Connection

This function says it cannot fail:

fn port() u16

There is no hidden exception path. You can see possible failure in the function signature.

That is one of Zig’s core design choices.

What “Internals” Means for Beginners

At this stage, you do not need to know the exact memory layout of every error union.

The useful mental model is this:

An error union is a tagged result:
one case is an error,
the other case is a success value.

So when you see:

!T

read it as:

error or T

When you see:

E!T

read it as:

one of the errors in E, or T

Then choose how to handle it:

try value

to propagate errors,

value catch ...

to handle errors,

or:

if (value) |success| {
    ...
} else |err| {
    ...
}

to handle both cases explicitly.

The Main Idea

An error union is Zig’s explicit way to represent fallible computation.

error{InvalidInput}!u8

means:

This computation can produce InvalidInput, or it can produce a u8.

The compiler makes you respect that type. You cannot quietly treat a fallible result as a normal value.

That is the point.

Failure is part of the type, so failure is part of the program’s design.