Skip to content

Error Union Types

An error union type means:

An error union type means:

either an error,
or a normal value

You have already seen this shape:

!i32

This means:

either an error,
or an i32

More explicitly, Zig can also write:

SomeError!i32

This means:

either one error from SomeError,
or an i32

So an error union joins two things together:

error set + success type

For example:

const DivideError = error{
    DivisionByZero,
};

fn divide(a: i32, b: i32) DivideError!i32 {
    if (b == 0) {
        return error.DivisionByZero;
    }

    return a / b;
}

The return type is:

DivideError!i32

That means the function can produce one of two results:

error.DivisionByZero

or:

an i32 value

The caller must deal with both possibilities.

Why This Type Exists

Many operations can fail, but still have a useful result when they succeed.

Opening a file can fail, but if it succeeds, you get a file handle.

Parsing a number can fail, but if it succeeds, you get a number.

Allocating memory can fail, but if it succeeds, you get a slice or pointer.

Reading from a socket can fail, but if it succeeds, you get bytes.

That is exactly what error unions model.

They say:

this operation has a success value,
but it may return an error instead

This is more precise than returning a magic value like -1, null, or false.

A Simple Parse Example

Suppose we want to parse a digit.

const ParseError = error{
    InvalidDigit,
};

fn parseDigit(c: u8) ParseError!u8 {
    if (c < '0' or c > '9') {
        return error.InvalidDigit;
    }

    return c - '0';
}

This function returns ParseError!u8.

So the caller cannot treat the result as a plain u8 immediately.

This is wrong:

const n: u8 = parseDigit('7');

The function does not return only a u8. It returns either an error or a u8.

You must unwrap it.

Unwrapping with try

The most common way to unwrap an error union is try.

const n: u8 = try parseDigit('7');

This means:

if parseDigit succeeds, put the u8 value into n
if parseDigit fails, return the error from the current function

Because try can return an error from the current function, the current function must also be allowed to fail.

pub fn main() !void {
    const n = try parseDigit('7');
    std.debug.print("{}\n", .{n});
}

Here, main returns !void, so it can propagate errors.

Unwrapping with catch

Use catch when you want to handle the error immediately.

const n = parseDigit('x') catch 0;

This means:

if parseDigit succeeds, use the parsed digit
if parseDigit fails, use 0 instead

So n becomes 0.

You can also inspect the error:

const n = parseDigit('x') catch |err| {
    std.debug.print("parse failed: {}\n", .{err});
    return;
};

Inside the catch block, err is the error value.

Error Union Values Are Not Plain Values

This is a key beginner mistake.

If a function returns !i32, you do not have an i32 yet.

You have a value that may contain an i32.

So this does not work:

const result = divide(10, 2);
std.debug.print("{}\n", .{result});

The variable result has an error union type, not a plain integer type.

You need one of these:

const result = try divide(10, 2);

or:

const result = divide(10, 2) catch 0;

or:

const result = divide(10, 2) catch |err| {
    std.debug.print("division failed: {}\n", .{err});
    return;
};

Only after unwrapping do you have the success value.

Returning Success and Returning Failure

Inside a function with an error union return type, you can return either kind of value.

fn divide(a: i32, b: i32) DivideError!i32 {
    if (b == 0) {
        return error.DivisionByZero;
    }

    return a / b;
}

This line returns failure:

return error.DivisionByZero;

This line returns success:

return a / b;

The return type allows both.

That is why the type is called an error union. It is a union of two possibilities.

Explicit Error Set vs Inferred Error Set

This version names the error set:

fn divide(a: i32, b: i32) DivideError!i32 {

This version lets Zig infer it:

fn divide(a: i32, b: i32) !i32 {

Both are error unions.

The difference is the error set.

!i32

means:

some inferred error set, or i32

while:

DivideError!i32

means:

DivideError, or i32

For teaching examples, !i32 is shorter.

For serious public APIs, an explicit error set often communicates more clearly.

Error Union with void

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

For those, use:

!void

This means:

either an error,
or success with no value

Example:

fn saveConfig() !void {
    // write file
}

If the function succeeds, there is no result to use. Success only means the operation completed.

If it fails, it returns an error.

This shape is common in Zig.

You will see it in functions that write files, create directories, initialize resources, send data, or run setup steps.

Error Union with Slices and Pointers

Error unions are often used with allocated memory.

fn makeBuffer(allocator: std.mem.Allocator) ![]u8 {
    return try allocator.alloc(u8, 1024);
}

This returns:

![]u8

That means:

either allocation failed,
or you get a mutable byte slice

A caller might use it like this:

const buffer = try makeBuffer(allocator);
defer allocator.free(buffer);

The try unwraps the slice. After that, buffer is a normal []u8.

The defer makes sure the buffer is freed later.

Error Union with Optionals

Do not confuse error unions with optionals.

An optional says:

there may be no value

An error union says:

there may be a failure

Optional:

?i32

means:

null or i32

Error union:

!i32

means:

error or i32

They answer different questions.

Use an optional when absence is normal and does not need a reason.

Use an error union when failure has a reason.

Example optional:

fn findIndex() ?usize {
    return null;
}

This means no index was found.

Example error union:

fn readFile() ![]u8 {
    // may fail because file is missing, permission is denied, etc.
}

This means the operation failed for a specific reason.

Combining Error Union and Optional

Sometimes you need both.

fn findUser(id: u64) !?User {
    // ...
}

Read this carefully:

!?User

It means:

either an error,
or an optional User

So there are three possible outcomes:

error: database failed
success: user was found
success: user was not found

This is useful when “not found” is not an error.

For example, a database failure is an error. But searching for a user and finding no match may be a valid result.

A caller might write:

const maybe_user = try findUser(42);

if (maybe_user) |user| {
    std.debug.print("found user: {s}\n", .{user.name});
} else {
    std.debug.print("user not found\n", .{});
}

First, try handles the error possibility.

Then, if handles the optional possibility.

The Caller Must Choose

When you call a function that returns an error union, you must choose what to do with failure.

You can propagate it:

const value = try parseDigit(c);

You can replace it with a fallback:

const value = parseDigit(c) catch 0;

You can inspect it:

const value = parseDigit(c) catch |err| switch (err) {
    error.InvalidDigit => 0,
};

You can also deliberately ignore it, but Zig makes that decision visible.

The key idea is that failure does not disappear.

The Core Idea

An error union type is Zig’s way to put success and failure into one return type.

ErrorSet!T

means:

either an error from ErrorSet,
or a value of type T

You cannot use the success value until you unwrap the error union.

That is the discipline Zig wants: before using a result, first decide what happens if the operation failed.