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
@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:
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
// 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:
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.
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.
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 hereAn 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:
parse_positive(-1); // error: the error this call can return is ignored
parse_positive(-1) or {}; // fine: the error is discarded, visiblyA 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:
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:
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:
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:
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:
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:
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
throwthat skips your cleanup - Composable —
or returnchains naturally through call stacks - No exceptions — no stack unwinding, no catch blocks, no surprise control flow
Last modified: