6. Error Handling¶
This is the chapter where Sushi's personality really shows. In Python a function that
fails throws an exception that may sail silently past three callers before someone catches
it (or no one does). In Java you juggle checked exceptions and null. Sushi takes a
different oath: failure is a value, and the type system makes you deal with it. A
function doesn't return "an i32, or maybe an explosion." It returns a Result<i32, E> —
success or error, right there in the type — and the compiler won't let you forget which
one you're holding.
By the end of this chapter you'll understand Result<T, E>, the ?? propagation operator,
the Maybe<T> optional type, and the small set of patterns that keep main warning-free.
Result<T, E>, and why it exists¶
You already met Result in Chapter 1: main ends with return Result.Ok(0). That wasn't
ceremony. Every function in Sushi returns a Result<T, E> — a value that is either:
Result.Ok(value)— success, carrying aT, orResult.Err(error)— failure, carrying anE.
Here's a function that can fail, and two ways to handle the outcome at the call site:
if (result): (Ok runs the if, Err runs the else) and .realise(default) (covered
later).
# A function that succeeds returns Result.Ok(value).
# A function that fails returns Result.Err(error).
fn halve(i32 n) i32:
if (n % 2 != 0):
return Result.Err(StdError.Error)
return Result.Ok(n / 2)
fn main() i32:
# if (result): runs the Ok branch; else runs the Err branch.
let Result<i32, StdError> even = halve(84)
if (even):
println("84 halved is {even.realise(0)}")
else:
println("84 was odd?!")
let Result<i32, StdError> odd = halve(7)
if (odd):
println("7 halved is {odd.realise(0)}")
else:
println("7 is odd, cannot halve cleanly")
return Result.Ok(0)
Output:
84 halved is 42
7 is odd, cannot halve cleanly
Two details to absorb:
- The function's declared return type is
i32, but the body returnsResult.Ok(...)/Result.Err(...). That's implicit wrapping: writingfn halve(i32 n) i32actually meansResult<i32, StdError>. You write the value type; Sushi wraps it inResultfor you. (Recap from Chapter 4.) StdErroris the built-in default error type.StdError.Erroris its catch-all variant — fine for "something went wrong" when you don't need detail.
Why not just exceptions?
Exceptions are invisible in a function's signature — you can't tell by looking whether a
call might blow up. Result<T, E> puts the failure mode in the type, so the compiler
can prove you handled it. The cost is a little more typing; the payoff is whole
categories of "I forgot that could fail" bugs that simply cannot compile.
Custom error types¶
StdError.Error is fine for quick programs, but real code wants to say what went wrong.
Define an enum and name it as the error type with the T | ErrorEnum syntax. Now callers
can match on the specific variant.
# Define your own error type as an enum.
enum JumpError:
DivisionByZero
NegativeInput
# `fn name(...) T | JumpError` means Result<T, JumpError>.
fn safe_divide(i32 a, i32 b) i32 | JumpError:
if (b == 0):
return Result.Err(JumpError.DivisionByZero)
if (a < 0):
return Result.Err(JumpError.NegativeInput)
return Result.Ok(a / b)
fn main() i32:
# match lets us inspect exactly which error occurred.
match safe_divide(42, 6):
Result.Ok(value) ->
println("42 / 6 = {value}")
Result.Err(JumpError.DivisionByZero) ->
println("cannot divide by zero")
Result.Err(JumpError.NegativeInput) ->
println("negative input rejected")
match safe_divide(10, 0):
Result.Ok(value) ->
println("10 / 0 = {value}")
Result.Err(JumpError.DivisionByZero) ->
println("cannot divide by zero")
Result.Err(JumpError.NegativeInput) ->
println("negative input rejected")
return Result.Ok(0)
Output:
42 / 6 = 7
cannot divide by zero
fn safe_divide(i32 a, i32 b) i32 | JumpError reads as "returns an i32, or fails with a
JumpError" — that is, Result<i32, JumpError>. Because the error is an enum, the match
can name each failure mode (DivisionByZero, NegativeInput) and the compiler checks that
you covered them all.
Don't mix | with an explicit Result
Use either the implicit form fn f() T | MyError or the fully explicit
fn f() Result<T, MyError> — never both at once. Writing
fn f() Result<T, E1> | E2 is a contradiction and the compiler rejects it (CE2085).
The ?? propagation operator¶
Matching on every single call would get tedious fast. When a helper function just wants to
say "if this failed, fail too, with the same error," that's what ?? is for. Applied to a
Result, ?? either unwraps the Ok value or immediately returns the Err from
the enclosing function.
fn fetch_fuel(bool tank_present) i32:
if (tank_present):
return Result.Ok(50)
return Result.Err(StdError.Error)
fn fetch_distance() i32:
return Result.Ok(1000)
# `??` unwraps an Ok value, or returns the Err from THIS function immediately.
# It is RAII-safe (cleanup still runs) and zero-cost. Use it freely in helpers.
fn plan_jump(bool tank_present) i32:
let i32 fuel = fetch_fuel(tank_present)??
let i32 distance = fetch_distance()??
return Result.Ok(distance / fuel)
fn main() i32:
# In main we do NOT use ??. We unwrap with .realise(default) instead.
let i32 ok = plan_jump(true).realise(-1)
println("with tank: {ok}")
let i32 failed = plan_jump(false).realise(-1)
println("no tank: {failed}")
return Result.Ok(0)
Output:
with tank: 20
no tank: -1
Look at plan_jump. Each ?? collapses a whole match into one character:
let i32 fuel = fetch_fuel(tank_present)??
If fetch_fuel returned Result.Err, plan_jump returns that same error right there, and
the lines below never run. If it returned Result.Ok(50), fuel is plainly 50. The
error types must match exactly — ?? does not silently convert one error enum into another.
?? is RAII-safe and zero-cost
When ?? bails out early, Sushi still runs the cleanup for anything you'd allocated so
far (its RAII destructors fire on the error path too) — no leaks, even on the unhappy
path. And it compiles down to a plain branch-and-return: there's no hidden exception
machinery or runtime tax. It's syntax sugar over the match you'd otherwise write by
hand.
.realise(default) for safe unwrapping¶
Sometimes you don't want to propagate an error — you just want a sensible fallback.
.realise(default) unwraps the Ok value, or hands back default if it's an Err. We've
been using it already; here it is spelled out:
let i32 ok = plan_jump(true).realise(-1) # Ok(20) -> 20
let i32 failed = plan_jump(false).realise(-1) # Err -> -1
This is the workhorse for turning a Result into a plain value without branching, and —
as we'll see in a moment — it's one of the main-safe ways to consume results.
Maybe<T>: "a value, or nothing"¶
Result<T, E> answers "did it succeed, and if not, why?" Sometimes you don't have a why —
there's simply a value present or absent. A lookup that finds nothing isn't an error; it's
just empty. For that, Sushi has Maybe<T>:
Maybe.Some(value)— there's a value,Maybe.None()— there isn't.
This is Sushi's replacement for null and for sentinel values like -1. You met it in
Chapter 5: string.find() returns Maybe<i32>. Its handy methods are .is_some(),
.is_none(), .realise(default) (same idea as on Result), and .expect(msg) (unwrap or
crash with a message — use only when absence would be a genuine bug).
# Maybe<T> models "a value, or nothing" without sentinels like -1 or null.
# crew is borrowed (&peek) so the caller keeps it and can search again; a by-value
# string[] would be moved into find_index and freed there.
fn find_index(&peek string[] crew, string name) Maybe<i32>:
let i32 i = 0
while (i < crew.len()):
let Maybe<string> member = crew.get(i)
if (member.realise("") == name):
return Result.Ok(Maybe.Some(i))
i := i + 1
return Result.Ok(Maybe.None())
fn main() i32:
let string[] crew = from(["Arthur", "Ford", "Zaphod"])
println("Crew of {crew.len()} aboard")
# find_index returns Result<Maybe<i32>>, so unwrap the Result with match,
# then inspect the Maybe.
match find_index(&peek crew, "Ford"):
Result.Ok(spot) ->
# .is_some() / .is_none() return bools.
if (spot.is_some()):
println("Ford is aboard")
# .realise(default) gives the value or a fallback.
println("Ford index (or -1): {spot.realise(-1)}")
Result.Err(_) ->
println("search failed")
match find_index(&peek crew, "Vogon"):
Result.Ok(spot) ->
if (spot.is_none()):
println("No Vogons aboard, thankfully")
println("Vogon index (or -1): {spot.realise(-1)}")
Result.Err(_) ->
println("search failed")
return Result.Ok(0)
Output:
Crew of 3 aboard
Ford is aboard
Ford index (or -1): 1
No Vogons aboard, thankfully
Vogon index (or -1): -1
Because find_index is a normal function it returns Result<Maybe<i32>, StdError> — two
layers. We peel the Result with match, then inspect the Maybe inside. (Result.Err(_)
uses _ to ignore the bound error: a match arm for Err must bind something, and _
says "I don't care about it" without tripping an unused-variable warning.)
Don't use ?? in main()¶
Here's the one rule that trips up newcomers. The ?? operator is wonderful in helper
functions, but using it in main triggers a compiler warning, CW2511:
CW2511:
??operator used inmain()(consider explicit error handling)
Why discourage it? main is the top of the call stack — there's nowhere left to propagate
to. If main propagated an error, your program would exit with an opaque failure and no
explanation. Sushi nudges you to handle errors deliberately at the boundary instead. (In
this tutorial, treat any warning as a failure to fix — a clean build has exit code 0.)
The fix is to consume results explicitly. There are three main-safe patterns:
match— when you want to handle Ok and Err differently..realise(default)— when a fallback value is enough.if (result):— for a quick Ok/else split.
fn risky(bool ok) i32:
if (ok):
return Result.Ok(7)
return Result.Err(StdError.Error)
fn main() i32:
# Pattern A: match on the Result.
match risky(true):
Result.Ok(v) ->
println("matched ok: {v}")
Result.Err(_) ->
println("matched err")
# Pattern B: .realise(default) for a fallback value.
let i32 value = risky(false).realise(0)
println("realise fallback: {value}")
# Pattern C: if (result): for a simple ok/err split.
let Result<i32, StdError> r = risky(true)
if (r):
println("if ok: {r.realise(0)}")
else:
println("if err")
return Result.Ok(0)
Output:
matched ok: 7
realise fallback: 0
if ok: 7
None of those use ??, so the program builds cleanly. Save ?? for the helpers that
main calls — that's exactly where its early-return magic belongs.
The shape of a tidy program
A common, comfortable structure: small helper functions that lean on ?? to chain
fallible steps, and a main that calls them and resolves the final Result with
match or .realise(). Errors propagate cleanly through the middle and get handled
once, at the edge.
What you learned¶
- Every Sushi function returns
Result<T, E>:Result.Ok(value)orResult.Err(error). - Writing
fn f() Timplicitly wraps toResult<T, StdError>;fn f() T | MyErrorlets you supply a custom error enum. ??unwrapsOkor propagatesErrfrom the enclosing function — RAII-safe, zero-cost, and meant for helper functions (notmain)..realise(default)unwraps with a fallback;if (result):splits Ok from Err.Maybe<T>(Maybe.Some/Maybe.None) models presence vs. absence — Sushi'snullreplacement — with.is_some(),.is_none(),.realise(), and.expect().- Using
??inmainwarns with CW2511; handle errors there withmatch,.realise(), orif (result):instead.
Next we put values in bulk. On to Arrays.