# Designing Error Boundaries

### Designing Error Boundaries

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.

```zig
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:

```zig
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:

```zig
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 level | Boundary decision |
|---|---|
| `error.FileNotFound` | print `missing config file` |
| `error.InvalidDigit` | print `port must be a number` |
| `error.OutOfMemory` | stop immediately |
| network timeout | retry, then fail |
| malformed user input | ask 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:

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

    // parse ...
}
```

Better design:

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

    // parse ...
}
```

Then handle it at the boundary:

```zig
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.

```zig
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.

