Skip to content

Custom Error Types

Most Zig programs start with small error sets:

Most Zig programs start with small error sets:

const ParseError = error{
    InvalidDigit,
    EmptyInput,
};

This is enough for many cases.

But larger programs often need more structure. Different modules may define their own error sets. Some errors may need to be grouped, mapped, or exposed differently across API boundaries.

This chapter explains how to design and organize your own error types.

Error Sets Are Types

An error set is a real Zig type.

const NetworkError = error{
    ConnectionFailed,
    Timeout,
    ConnectionClosed,
};

NetworkError is now a named type.

You can use it in function signatures:

fn connect() NetworkError!void {
    return error.Timeout;
}

You can store it in variables:

const err: NetworkError = error.Timeout;

You can compare it:

if (err == error.Timeout) {
    // ...
}

Error sets are lightweight typed collections of named failures.

Separate Error Types by Domain

A good rule is:

define errors near the subsystem that owns them

For example:

const FileError = error{
    FileNotFound,
    PermissionDenied,
    DiskFull,
};

const ParseError = error{
    InvalidSyntax,
    UnexpectedToken,
    UnterminatedString,
};

const NetworkError = error{
    Timeout,
    ConnectionClosed,
    InvalidPacket,
};

Each subsystem describes its own failures.

This makes the code easier to reason about.

A parser should not return DiskFull.

A networking module should not return UnexpectedToken.

The error types describe the domain.

Grouping Related Errors

Suppose a config loader reads a file and parses it.

Internally, it may use two subsystems:

file reading
config parsing

Each subsystem has its own error set.

const FileError = error{
    FileNotFound,
    PermissionDenied,
};

const ParseError = error{
    InvalidSyntax,
    MissingField,
};

You can combine them:

const ConfigError = FileError || ParseError;

Now ConfigError contains:

error.FileNotFound
error.PermissionDenied
error.InvalidSyntax
error.MissingField

This is useful when a higher-level operation depends on multiple lower-level systems.

Example: Config Loader

const std = @import("std");

const FileError = error{
    FileNotFound,
    PermissionDenied,
};

const ParseError = error{
    InvalidSyntax,
    MissingField,
};

const ConfigError = FileError || ParseError;

fn readConfigFile() FileError![]const u8 {
    return error.FileNotFound;
}

fn parseConfig(text: []const u8) ParseError!void {
    _ = text;
    return error.InvalidSyntax;
}

fn loadConfig() ConfigError!void {
    const text = try readConfigFile();
    try parseConfig(text);
}

loadConfig can now propagate errors from both subsystems.

Creating Application-Level Errors

Sometimes you do not want callers to see all internal errors.

Suppose your application only wants these public errors:

const AppError = error{
    ConfigFailed,
    NetworkFailed,
};

You can map lower-level errors into these broader categories.

fn startApplication() AppError!void {
    loadConfig() catch {
        return error.ConfigFailed;
    };

    connectToServer() catch {
        return error.NetworkFailed;
    };
}

This creates a cleaner public API.

The caller does not need to know whether config loading failed because of invalid syntax or missing permissions. The application treats all config problems the same way.

Precise Errors vs Broad Errors

There is a tradeoff.

Very precise error sets:

const ParseError = error{
    InvalidCharacter,
    InvalidEscape,
    UnterminatedString,
    InvalidNumber,
    MissingComma,
    MissingColon,
    DuplicateKey,
};

Advantages:

better debugging
more control for callers
more detailed diagnostics

Disadvantages:

larger APIs
more handling logic
more maintenance

Very broad error sets:

const ParseError = error{
    InvalidInput,
};

Advantages:

simple API
easy handling

Disadvantages:

less information
harder debugging
less caller control

The correct size depends on the use case.

Library code often benefits from more precision.

Application-level code often benefits from simpler categories.

Error Translation

One subsystem may expose detailed errors internally but simpler errors externally.

Example:

const JsonError = error{
    InvalidEscape,
    InvalidUnicode,
    UnexpectedEnd,
};

const ConfigError = error{
    InvalidConfig,
};

Translate like this:

fn loadConfig(text: []const u8) ConfigError!void {
    parseJson(text) catch {
        return error.InvalidConfig;
    };
}

This is called error translation or error mapping.

It creates a boundary between internal implementation details and external API design.

Custom Error Types for Libraries

A library should usually define its own named error sets.

Good:

pub const HttpError = error{
    InvalidUrl,
    ConnectionFailed,
    Timeout,
    InvalidResponse,
};

Less good:

pub fn request() anyerror!Response {
    // ...
}

Why?

Because callers need to know what failures are part of the contract.

Named error sets become documentation.

A caller can inspect the type and understand the API.

Shared Error Names

Error names are global.

These refer to the same error name:

error.Timeout

even if they appear in different error sets.

const NetworkError = error{
    Timeout,
};

const DatabaseError = error{
    Timeout,
};

Both contain the same global error name: error.Timeout.

This is why error names should be reasonably generic and reusable.

Good reusable names:

error.Timeout
error.OutOfMemory
error.PermissionDenied
error.ConnectionClosed

Overly specific names can become awkward:

error.HttpClientConnectionClosedUnexpectedlyDuringUpload

If an error name becomes extremely long, it may indicate the API boundary is wrong or the subsystem responsibilities are mixed together.

Error Names Are Not Messages

An error name is not a user-facing string.

This is not ideal:

error.YouEnteredAnInvalidPasswordPleaseTryAgain

Use concise symbolic names:

error.InvalidPassword

Then convert them into user-facing messages separately.

login() catch |err| switch (err) {
    error.InvalidPassword => {
        std.debug.print("incorrect password\n", .{});
    },
    error.AccountLocked => {
        std.debug.print("account locked\n", .{});
    },
};

This separation matters because:

internal error names are for programmers
messages are for users

Extending Error Sets

You can build larger error sets from smaller ones.

const ReadError = error{
    FileNotFound,
    PermissionDenied,
};

const DecodeError = error{
    InvalidFormat,
};

const ImageError = ReadError || DecodeError || error{
    UnsupportedColorMode,
};

Now ImageError contains all these errors together.

This helps when constructing layered systems.

Returning Different Error Sets

Different functions can expose different levels of detail.

Low-level function:

fn tokenize(text: []const u8) TokenizeError!Tokens {
    // ...
}

Higher-level parser:

fn parse(text: []const u8) ParseError!Ast {
    // ...
}

Application-level API:

fn compile(text: []const u8) CompileError!Binary {
    // ...
}

Each layer decides how much detail to expose.

That is part of API design.

Avoid Giant Global Error Sets

A common beginner mistake is creating one huge error type for the whole program.

const AppError = error{
    FileNotFound,
    PermissionDenied,
    InvalidSyntax,
    ConnectionClosed,
    Timeout,
    DiskFull,
    MissingField,
    InvalidPassword,
    DatabaseCorrupted,
    UnsupportedVersion,
    OutOfMemory,
    // hundreds more...
};

This becomes difficult to understand and maintain.

Prefer smaller subsystem-level error sets.

Combine them only when needed.

Custom Errors and Testing

Precise error types also improve tests.

Example:

try std.testing.expectError(
    error.InvalidSyntax,
    parseConfig("{ invalid"),
);

This test checks for one exact failure.

Precise errors make tests stronger and easier to debug.

A Realistic Layered Example

Imagine this structure:

lexer
parser
compiler
application

Each layer has its own errors.

const LexerError = error{
    InvalidCharacter,
    UnterminatedString,
};

const ParserError = LexerError || error{
    UnexpectedToken,
};

const CompileError = ParserError || error{
    UnknownVariable,
    InvalidType,
};

Now higher layers naturally inherit lower-layer failures.

The types describe the architecture.

The Core Idea

Custom error types let you organize failure intentionally.

A good error type:

belongs to a subsystem
describes meaningful failures
helps callers make decisions
does not expose unnecessary details

Error sets are not only about reporting problems. They are part of the structure of the program itself.