Error Handling

Tape uses error returns — functions that can fail return T or Error. No exceptions, no stack unwinding, no hidden control flow.

Basic pattern

tape
@profile(t1);
import io from "io";

enum ParseErr : u8 {
    BadInput = 1;
    Overflow = 2;
}

fn parse_positive(val: i64) -> i64 or ParseErr {
    if (val < 0) { return ParseErr.BadInput; }
    if (val > 1000000) { return ParseErr.Overflow; }
    return val * 2;
}

fn process(val: i64) -> i64 or ParseErr {
    var doubled: i64 = parse_positive(val) or return;
    return doubled + 1;
}

pub fn main() -> i32 {
    var result: i64 = process(21) or return 1;
    io.println("{result}");
    return 0;
}

How it works

A function declares that it can fail by using or ErrorType in its return type:

tape
fn parse_positive(val: i64) -> i64 or ParseErr {
    if (val < 0) { return ParseErr.BadInput; }
    return val * 2;
}

The caller must handle the error explicitly:

  • or return — propagate the error to the caller. The caller must return the same error type; with a different one, map it (or return MyErr.Io;) or lift it into a tagged union (below)
  • or { block } — handle the error inline
tape
// Propagate (caller must also return or Error):
var result: i64 = parse_positive(val) or return;

// Handle inline:
var result: i64 = divide(10, 0) or {
    io.println("division failed");
    return 1;
};

// Log the error description:
var result: i64 = parse_positive(-1) or {
    io.println(ParseErr.desc(err));  // "invalid input"
    return 1;
};

Both are expressions of the success type, so in parentheses they can be used like any value of it, a method’s receiver included:

tape
let name: string = (load_user(id) or return).display_name();
let n: i64 = (read_text(path) or { "" }).len;

Instead of leaving the function, the block can supply a value: its last expression, written without a ;, is the result. It must have the success type.

tape
let port: i64 = parse_port(text) or { 8080 };
let port2: i64 = parse_port(text) or {
    io.println("bad port, using the default");
    8080
};

The ; matters: or { 8080; } is a block with no value, so using it where a value is needed is a compile error that tells you to drop the ;. A block that leaves instead (return, break, continue, @trap) needs no value. Statements keep their own ; as everywhere else: or { return 1; }, or { break; }; only an expression such as @trap("...") may end the block without one.

tape
let a: i64 = parse_port(text) or { 8080; };     // error: the ';' makes the block void
let b: i64 = parse_port(text) or { return 1; }; // fine: the block leaves
parse_port(text) or { log_bad(text); };         // fine: no value is wanted here

An error cannot be ignored by accident. Calling a fallible function as a plain statement is a compile error, because its error would vanish without a trace:

tape
parse_positive(-1);          // error: the error this call can return is ignored
parse_positive(-1) or {};    // fine: the error is discarded, visibly

A void or E function that reaches its closing brace succeeds, just like one that ends with return;.

Error enums

The error type is an enum or a tagged union of your own. Most errors are enum values, with optional description strings:

tape
enum ParseErr : u8 {
    BadInput = 1: "invalid input";
    Overflow = 2: "value too large";
}

The description string is available at runtime via the enum’s .desc() method.

For errors that carry data, use a tagged union as the error type:

tape
tagged AppError {
    Io { code: IoError; path: string; }
    Parse { err: ParseErr; line: i64; col: i64; }
}

A tagged union also collects the errors of the functions you call. Name a variant after or return and the error passed on is wrapped in it:

tape
tagged FetchError {
    Io { e: IoError; }
    Parse { e: ParseErr; }
    Closed;
}

fn fetch(path: string) -> Config or FetchError {
    let text = read_file(path) or return FetchError.Io;    // FetchError.Io { e: err }
    return parse_config(text) or return FetchError.Parse;
}

The variant must belong to the function’s error type and have exactly one field, whose type is the error’s type; anything else is a compile error that names what it found. A variant without fields, like FetchError.Closed, is a value: or return FetchError.Closed returns it, whatever the error was. Nothing is wrapped without being asked: a bare or return still needs the same error type.

The success value can be an enum too. Each enum is its own type, so return picks the side by the value’s type, not by it being an enum:

tape
enum Mode : u8 { Slow = 0; Fast = 1; }
enum ModeErr : u8 { Unknown = 1; }

fn parse_mode(code: i64) -> Mode or ModeErr {
    if (code == 1) { return Mode.Fast; }    // success
    if (code == 0) { return Mode.Slow; }    // success
    return ModeErr.Unknown;                 // error
}

The success and error types must differ: Mode or Mode is a compile error, since a returned Mode could be either. And the error type is always an enum or a tagged union: -> Point or i64, -> Point or bool or an error struct are compile errors that ask you to declare an error enum.

Broken contracts: @trap

An error return is for failures the caller should handle. When a function’s caller broke its contract (passed an index it could have checked, a destination that’s too short), the function can stop the program instead:

tape
fn copy_into(dst: []u8, src: []const u8) {
    if (dst.len < src.len) { @trap("destination too short"); }
    // ...
}

@trap("message") prints trap: message at file:line:col to stderr and exits with code 101, exactly like an out-of-bounds index or an unwrap of null. It doesn’t unwind and defer blocks don’t run. The message must be a string literal. Because it never returns, it can end a block that would otherwise need a value:

tape
let n: i64 = parse(text) or { @trap("config is checked at startup"); };

In a #test region, @assert_trap(expr) checks that expr traps.

Design principles

  • Zero cost on success — just a tag check on the return value
  • Visible at every call site — no hidden throw that skips your cleanup
  • Composable — or return chains naturally through call stacks
  • No exceptions — no stack unwinding, no catch blocks, no surprise control flow

Last modified: