Skip to content

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 a T, or
  • Result.Err(error) — failure, carrying an E.

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 returns Result.Ok(...) / Result.Err(...). That's implicit wrapping: writing fn halve(i32 n) i32 actually means Result<i32, StdError>. You write the value type; Sushi wraps it in Result for you. (Recap from Chapter 4.)
  • StdError is the built-in default error type. StdError.Error is 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 in main() (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) or Result.Err(error).
  • Writing fn f() T implicitly wraps to Result<T, StdError>; fn f() T | MyError lets you supply a custom error enum.
  • ?? unwraps Ok or propagates Err from the enclosing function — RAII-safe, zero-cost, and meant for helper functions (not main).
  • .realise(default) unwraps with a fallback; if (result): splits Ok from Err.
  • Maybe<T> (Maybe.Some / Maybe.None) models presence vs. absence — Sushi's null replacement — with .is_some(), .is_none(), .realise(), and .expect().
  • Using ?? in main warns with CW2511; handle errors there with match, .realise(), or if (result): instead.

Next we put values in bulk. On to Arrays.