Errors
Metaxu splits errors into three stories. Compile-time errors (types, borrows, undefined names) stop the build; chapters 6 and 10 cover them. Runtime failures can be contained with try/catch. And for errors that are part of a function's contract, the library gives you Option, Result, and three effects built on handlers.
try/catch
try { body } catch e { handler } is an expression. If the body completes, its value is the result and the catch never runs. If a runtime failure occurs anywhere in the body's dynamic extent, including inside functions it calls, the rest of the body is abandoned and the handler's value is the result instead:
metaxufn main() -> int {
let fine = try { 41 + 1 } catch e { 0 };
let broken = try {
let v = Vec.new();
v.push(10);
v[3]
} catch e {
print("caught: " + e);
-1
};
print(fine);
print(broken);
0
}
outputcaught: index out of bounds: 3 (length 1)
42
-1
e binds the failure's message: the plain text as raised, with no file paths and no compiler context attached, so the same program catches the same string on every machine and both backends (docs/try_catch.md pins this byte for byte). Nesting behaves the way handlers do: the innermost try catches, and a failure raised inside a catch block propagates outward rather than being caught by its own try.
What raises, and what catch sees
The catchable failures are: builtin contract violations (indexing out of bounds, pop on an empty Vec, Tile.get outside the tile's shape, a shift count outside 0..63); a perform with no handler installed; a match that no pattern accepts at runtime; a trait method call whose receiver type has no impl (chapter 7 catches one); a failed file or process operation from std.fs and friends (chapter 12); and your own raise(message), which fails with exactly that text:
metaxufn parse_port(text: string) -> int {
let @mut value = 0;
let @mut i = 0;
while i < len(text) {
let d = "0123456789".find(text[i]);
if d < 0 { raise("parse_port: not a number: " + text) } else { () };
value = value * 10 + d;
i = i + 1
}
if value > 65535 { raise("parse_port: out of range: " + text) } else { () };
value
}
fn main() -> int {
print(parse_port("8080"));
print(try { parse_port("80x") } catch e { print(e); 0 - 1 });
print(try { parse_port("99999") } catch e { print(e); 0 - 1 });
0
}
output8080
parse_port: not a number: 80x
-1
parse_port: out of range: 99999
-1
The unhandled-perform case defines the division of labor between try and handle:
metaxueffect Parser { parse(input: string) -> int }
fn main() -> int {
let r = try {
perform Parser.parse("123")
} catch e {
print("caught: " + e);
0
};
print(r);
0
}
outputcaught: No handler for effect 'Parser'
0
Catching this failure is dynamic recovery, not a handler: the try does not discharge the effect statically, and it cannot resume the computation. When a handler is installed, it wins, and the try has nothing to catch:
metaxueffect Ask { ask() -> int }
fn main() -> int {
let v = handle Ask with {
ask() -> resume(5)
} in {
try { perform Ask.ask() + 1 } catch e { -1 }
};
print(v);
0
}
Not everything is catchable, on purpose. An assert failure, integer division by zero, and resuming a continuation twice are fatal on both backends. Recursion-budget exhaustion also passes through catch: a catch arm would run with the stack still at its ceiling. And compile-time errors are simply not runtime events; wrapping a type error in try does not turn it into one:
metaxu · rejectedfn main() -> int {
let r = try { 1 + "a" } catch e { 0 };
r
}
outputone value is required to be Int and String
Option and Result
For failures a caller is expected to handle, don't raise: return Option (Some/None) or Result (Ok/Err). Both are builtins; std.option and std.result add the combinators:
metaxuimport std.option as opt;
import std.result as res;
from std.parse import parse_int;
fn half(n: int) {
if n % 2 == 0 { Some(n / 2) } else { None }
}
fn main() -> int {
let a = opt.and_then(parse_int("84"), fn(n: int) -> half(n));
print(opt.unwrap_or(a, -1));
let r = opt.ok_or(parse_int("4x"), "not a number");
match r {
Ok(v) => print(v),
Err(e) => print("error: " + e)
};
print(opt.unwrap_or(res.ok(r), 0));
0
}
output42
error: not a number
0
parse_int answers an Option, and_then chains the next fallible step, ok_or converts to a Result with an error of your choosing, and res.ok goes back. All of them are plain matches.
The effectful error story
std.fail, std.throw, and std.early_return (designs ported from Ante's stdlib) treat failure as an algebraic effect: the failing code performs an operation, and a handler at the boundary decides what failure means. These handlers abort rather than resume.
Fail carries no payload. try_opt runs a computation and answers an Option; a multi-step computation aborts at the first failure with no per-step matching:
metaxufrom std.fail import Fail, try_opt;
from std.parse import parse_int_or_fail;
fn sum_fields(a: string, b: string) performs Fail -> int {
parse_int_or_fail(a) + parse_int_or_fail(b)
}
fn main() -> int {
match try_opt(fn() -> int { sum_fields("12", "30") }) {
Some(v) => print(v),
None => print("malformed")
};
match try_opt(fn() -> int { sum_fields("12", "3O") }) {
Some(v) => print(v),
None => print("malformed")
};
0
}
Throw is Fail with an error value. catch_ turns a throwing computation into a Result (the underscore is there because catch is a reserved word):
metaxufrom std.throw import Throw, catch_, throw_if;
fn withdraw(balance: int, amount: int) performs Throw -> int {
throw_if(amount > balance, "insufficient funds");
balance - amount
}
fn main() -> int {
match catch_(fn() -> int { withdraw(100, 30) }) {
Ok(left) => print("left: " + left.to_string()),
Err(e) => print("error: " + e)
};
match catch_(fn() -> int { withdraw(100, 300) }) {
Ok(left) => print(left),
Err(e) => print("error: " + e)
};
0
}
outputleft: 70
error: insufficient funds
EarlyReturn is the same mechanism used for control flow rather than failure: multi-level early exit without a dedicated statement.
metaxufrom std.early_return import EarlyReturn, with_early_return, return_if;
fn first_negative_index(v: Vec) -> int {
with_early_return(fn() -> int {
let @mut i = 0;
while i < len(v) {
return_if(v[i] < 0, i);
i = i + 1
}
-1
})
}
fn main() -> int {
let @mut v = Vec.new();
v.push(5);
v.push(-3);
v.push(8);
print(first_negative_index(v));
0
}
Which to reach for: Option/Result when the caller should decide at every step; Fail/Throw when a whole computation shares one failure boundary; try/catch when the failure is a violated contract you didn't plan for and you want the program to survive it anyway.