Error Handling
Bop uses a Result-shaped value model for recoverable errors, with two language features that make it ergonomic:
try— unwrap anOk(v)or propagate anErr(e)up to the enclosing function.try_call(f)— run a zero-arg callable, catch any runtime error, and return the outcome as aResult.
Both Result and RuntimeError are engine built-ins — always in scope, no import required. The combinators (is_ok, unwrap, map, and_then, …) are methods on the Result type, also always available.
The Result type
enum Result {
Ok(value),
Err(error),
}
By convention, Ok(v) carries the successful value and Err(e) carries whatever describes the failure — a string, a struct, anything. Values from fallible operations are the typical shape:
fn parse_positive(s) {
let n = s.to_int()
if n <= 0 {
return Err("must be positive, got {n}")
}
return Ok(n)
}
print(parse_positive("42")) // Result::Ok(42)
print(parse_positive("-3")) // Result::Err("must be positive, got -3")
Ok / Err shorthand
Ok(x) and Err(e) are parser-level sugar for Result::Ok(x) and Result::Err(e). The rewrite applies in both expression and pattern position, so you can write:
fn classify(n) {
if n > 0 { return Ok(n) }
return Err("non-positive")
}
print(match classify(5) {
Ok(v) => "ok: {v}",
Err(e) => "err: {e}",
})
// ok: 5
Bop’s case rules already reserve uppercase identifiers for types and variants, so Ok and Err can’t collide with a user fn or variable. The long form (Result::Ok(v), Result::Err(e)) still works — pick whichever reads better.
If a different enum happens to have its own Ok / Err variants, use the qualified MyEnum::Ok(x) form for those. The bare sugar always means Result::Ok / Result::Err.
The try operator
try expr evaluates expr and:
- If the result is
Result::Ok(v), unwraps it tov. - If the result is
Result::Err(e), immediately returnsefrom the enclosing function as-is (wrapped in the sameErrvariant the caller will see). - If the result is anything else (not
Result-shaped), raises a runtime error.
fn pipeline(s) {
let n = try parse_positive(s) // Err propagates; Ok unwraps to `n`
let doubled = try double_checked(n)
return Ok(doubled)
}
print(pipeline("21")) // Result::Ok(42)
print(pipeline("-3")) // Result::Err("must be positive, got -3")
Because try propagates by returning from the enclosing function, it only works inside fn bodies. A top-level try that hits an Err raises a runtime error — wrap the call site in a fn.
Unit-Ok
try Result::Ok with no payload (or try Result::Ok where Ok is a unit variant) yields none. Mostly relevant for APIs where the success case carries no meaningful value.
try_call(f)
Catch runtime errors from a zero-arg callable. Returns Result::Ok(value) on success or Result::Err(RuntimeError { message, line }) on a caught error.
let r = try_call(fn() { return 1 / 0 })
print(match r {
Result::Ok(v) => "got {v}",
Result::Err(RuntimeError { message, line }) =>
"failed at line {line}: {message}",
})
// failed at line 1: Division by zero
try_call is Bop’s answer to exception-like error handling without exceptions. It only catches non-fatal errors. Fatal conditions — step-budget exhaustion, memory-limit violation, host on_tick returning BopError::fatal — are not caught. That keeps the sandbox invariant intact: a runaway loop can’t wrap itself in try_call and keep going.
RuntimeError — the caught error shape
struct RuntimeError {
message, // string
line, // int — 1-indexed source line of the failing expression
}
You can construct one explicitly (it’s a regular struct), but most of the time you’ll see them as the payload inside Result::Err(...) returned from try_call.
Combinators — methods on Result
Every Result value has a small set of always-available methods. No import needed — Result is a built-in type and its combinators are engine-level methods.
print(Ok(1).is_ok()) // true
print(Err("oops").is_err()) // true
// unwrap_or — default on Err
print(Ok(10).unwrap_or(0)) // 10
print(Err("fail").unwrap_or(0)) // 0
// map — transform the Ok payload, pass Err through
print(Ok(5).map(fn(n) { return n * n })) // Result::Ok(25)
print(Err("x").map(fn(n) { return n * n })) // Result::Err("x")
// and_then — monadic bind (for chaining fallible steps)
fn halve(x) {
if x % 2 == 0 { return Ok((x / 2).to_int()) }
return Err("odd")
}
print(Ok(8).and_then(halve).and_then(halve)) // Result::Ok(2)
print(Ok(7).and_then(halve)) // Result::Err("odd")
Available: is_ok, is_err, unwrap, expect, unwrap_or, map, map_err, and_then. See Methods → Result for the full reference.
unwrap() and expect(msg) raise a runtime error on Err — use sparingly, and prefer try or pattern matching in production code.
When to use which
| Situation | Use |
|---|---|
| Writing a fallible function | Return Result::Ok(v) / Result::Err(e) |
| Chaining several fallible calls | try inside a fn, or r.and_then(f) |
| Running user-supplied code with a safety net | try_call(fn() { ... }) |
Handling every Err case explicitly | match |
You know it’s Ok and want the value | r.unwrap() / r.expect("...") (sparingly) |
Supplying a default on Err | r.unwrap_or(default) |
Fatal vs non-fatal
- Non-fatal (catchable by
try_call): division by zero, “variable not found”, type mismatches, host-raised errors viaBopError::runtime, wrong arg count, missing field, etc. - Fatal (not catchable): step-budget exceeded, memory-limit exceeded, fn-call-depth exceeded, host-raised
BopError::fatal.
A script can observe whether an error was fatal by inspecting whether try_call caught it — fatal errors propagate past try_call to the host.