Skip to content

Designing Error Boundaries

Most functions should not decide what an error means.

Most functions should not decide what an error means.

A parser should report error.InvalidDigit. A file function should report error.FileNotFound. An allocator should report error.OutOfMemory.

The outer layer of the program decides what to print, whether to retry, and whether to stop.

Consider a small program that reads a number from a file.

const std = @import("std");

fn readText(allocator: std.mem.Allocator, path: []const u8) ![]u8 {
    return try std.fs.cwd().readFileAlloc(allocator, path, 1024);
}

fn parseNumber(text: []const u8) !u32 {
    var n: u32 = 0;

    for (text) |c| {
        if (c == '\n') break;

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

        n = n * 10 + (c - '0');
    }

    return n;
}

pub fn main() !void {
    var gpa = std.heap.DebugAllocator(.{}){};
    defer _ = gpa.deinit();

    const allocator = gpa.allocator();

    const text = try readText(allocator, "number.txt");
    defer allocator.free(text);

    const n = try parseNumber(text);

    std.debug.print("{d}\n", .{n});
}

This is correct, but the user sees a raw error if something fails.

For a small test program, that is enough. For a command-line tool, it is usually too thin. The program should explain the operation that failed.

The lower functions should stay simple:

fn readText(allocator: std.mem.Allocator, path: []const u8) ![]u8 {
    return try std.fs.cwd().readFileAlloc(allocator, path, 1024);
}

fn parseNumber(text: []const u8) !u32 {
    var n: u32 = 0;

    for (text) |c| {
        if (c == '\n') break;

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

        n = n * 10 + (c - '0');
    }

    return n;
}

They report failure. They do not print.

The boundary handles policy:

pub fn main() void {
    run() catch |err| {
        std.debug.print("error: {}\n", .{err});
    };
}

fn run() !void {
    var gpa = std.heap.DebugAllocator(.{}){};
    defer _ = gpa.deinit();

    const allocator = gpa.allocator();

    const text = readText(allocator, "number.txt") catch |err| {
        std.debug.print("cannot read number.txt: {}\n", .{err});
        return err;
    };
    defer allocator.free(text);

    const n = parseNumber(text) catch |err| {
        std.debug.print("number.txt does not contain a valid number: {}\n", .{err});
        return err;
    };

    std.debug.print("{d}\n", .{n});
}

Now the message has context.

The error still returns from run. This lets main decide whether to print a final message, set an exit code, or perform other shutdown work.

An error boundary is a place where the program changes level.

Examples:

Lower levelBoundary decision
error.FileNotFoundprint missing config file
error.InvalidDigitprint port must be a number
error.OutOfMemorystop immediately
network timeoutretry, then fail
malformed user inputask again

The lower layer should return precise errors.

The boundary should translate them into program behavior.

Do not print in every helper function. That scatters policy across the program.

Poor design:

fn parsePort(text: []const u8) !u16 {
    if (text.len == 0) {
        std.debug.print("port is empty\n", .{});
        return error.EmptyPort;
    }

    // parse ...
}

Better design:

fn parsePort(text: []const u8) !u16 {
    if (text.len == 0) {
        return error.EmptyPort;
    }

    // parse ...
}

Then handle it at the boundary:

const port = parsePort(arg) catch |err| {
    std.debug.print("invalid port: {}\n", .{err});
    return;
};

This keeps the parser reusable. It can be used by a CLI, a test, a server, or a library.

Sometimes a boundary translates low-level errors into domain errors.

const ConfigError = error{
    MissingConfig,
    InvalidConfig,
    OutOfMemory,
};

fn loadConfig(allocator: std.mem.Allocator) ConfigError![]u8 {
    return std.fs.cwd().readFileAlloc(allocator, "config.txt", 4096) catch |err| switch (err) {
        error.FileNotFound => error.MissingConfig,
        error.OutOfMemory => error.OutOfMemory,
        else => error.InvalidConfig,
    };
}

The caller of loadConfig does not need to know every filesystem error. It only needs the errors that matter for configuration loading.

But do not translate too early. If the caller needs the original error, preserve it.

A useful rule:

Return detailed errors inside the program. Translate them at the edge.

Edges include:

  • main
  • CLI argument handling
  • config loading
  • HTTP handlers
  • public library functions
  • thread entry points
  • test helpers

Inside the program, prefer try.

At the edge, use catch and switch.

Exercise 8-25. Write run() !void and make main handle its error.

Exercise 8-26. Move printing out of a helper function and into main.

Exercise 8-27. Translate error.FileNotFound into error.MissingConfig.

Exercise 8-28. Write a parser that returns errors but never prints.