Skip to content

Catch

Exhaustively convert or discharge the error side of a Result expression.

Updated View as Markdown

Postfix catch attaches match arms to the Err side of a Result. The Ok payload passes through untouched; each arm decides what happens to one failure.

error LoadError {
    NotFound { id: int }
    PermissionDenied
    Storage { message: string }
}

fn load_name(id: int): string ! LoadError {
    if id < 0 { error NotFound { id: id } }
    return "ada"
}

fn describe(id: int): string {
    name := load_name(id) catch {
        NotFound { id: _ } => return "unknown"
        PermissionDenied => return "private"
        Storage { message: _ } => return "unavailable"
    }
    return name
}

fn main(): void {
    println(describe(1))
    println(describe(-1))
}

What an arm body means

This is the rule that governs every use of catch, and it is easy to guess wrong: an arm body produces a replacement error, not a replacement success value.

Every arm… Type of the catch expression
produces a value Result[T, E2], where E2 is the arm body type
diverges (return, error, break, continue) T — the plain success type

So a catch whose arms produce values does not unwrap anything. It rewrites the error channel and leaves you holding another Result:

error AuthError { InvalidToken, Expired }
error RequestError { Unauthorized }

fn authenticate(token: string): int ! AuthError { error Expired }

fn attempt(token: string): int ! RequestError {
    // `converted` is Result[int, RequestError] — still two-channel.
    converted := authenticate(token) catch {
        InvalidToken => RequestError.Unauthorized
        Expired => RequestError.Unauthorized
    }
    return converted?
}

fn main(): void {
    token := "t"
    println("${attempt(token).is_ok()}")
}

A catch that only substitutes a fallback value is therefore a type error, not a recovery:

error ConfigError { Missing, Invalid }
struct Config { retries: int }

fn read_config(): Config ! ConfigError { error Missing }

fn load(): Config {
    // Arms produce `Config`, so this is Result[Config, Config].
    config := read_config() catch {
        Missing => Config { retries: 3 }
        Invalid => Config { retries: 1 }
    }
    return config
}

To recover to a plain value, make every arm leave the expression.

Recovery by diverging

When each arm returns, the catch yields the success type directly.

error SourceError { NotFound, Other }

fn source(): int ! SourceError { error Other }

fn call(): int {
    // Every arm returns, so `value` is a plain `int`.
    value := source() catch {
        NotFound => return 0
        _ => return -1
    }
    return value
}

For a single fallback value, the Result methods are shorter than a diverging catch:

error SourceError { Bad }

fn source(): int ! SourceError { error Bad }

fn call(): int {
    return source().unwrap_or(0)
}

fn call_lazily(): int {
    return source().unwrap_or_else(e => 0)
}

fn main(): void {
    println("${call()} ${call_lazily()}")
}

Reach for catch when different variants deserve different treatment, and for unwrap_or / unwrap_or_else when they do not.

Conversion

The common use of catch is mapping one error type onto another at a layer boundary. Add ? to convert and propagate in one expression.

error AuthError { InvalidToken, Expired }
error RequestError { Unauthorized, Internal }

fn authenticate(token: string): int ! AuthError {
    if token.is_empty() { error InvalidToken }
    return 7
}

fn current_user(token: string): int ! RequestError {
    user := authenticate(token) catch {
        InvalidToken => RequestError.Unauthorized
        Expired => RequestError.Unauthorized
    }?
    return user
}

fn main(): void {
    token := "tok"
    println("${current_user(token).is_ok()}")
}

Every normally completing arm must agree on one target error type. Variant and ErrorType.Variant both name a variant; the qualified form is useful when two error types share a variant name.

Direct error exits

An arm can use error to leave the enclosing fallible function immediately. Because those arms diverge, the binding receives the success payload.

error SourceError { Bad, Other }
error Mapped { FromBad, FromOther }

fn source(): int ! SourceError { error Bad }

fn use_source(): int ! Mapped {
    value := source() catch {
        Bad => error FromBad
        Other => error FromOther
    }
    return value
}

fn main(): void {
    println("${use_source().is_ok()}")
}

This reads well when the mapping is the whole point and no local recovery exists.

Patterns and guards

Catch arms use the same patterns as match, including payload destructuring and guards.

error BackendError { Backend { code: int } }

fn request(): int ! BackendError { error Backend { code: 409 } }

fn call(): int {
    value := request() catch {
        Backend { code } if code == 409 => return 0
        Backend { code: _ } => return -1
    }
    return value
}

Guards refine selection but do not remove a variant from the exhaustiveness set: several guarded arms for one variant still need an arm that accepts it unguarded.

Bind error payloads as err or e. error is a keyword and cannot be used as an identifier anywhere in Atoll.

Exhaustiveness

Every reachable error variant must be covered, exactly as in match.

error SourceError { Bad, Other }

fn source(): int ! SourceError { error Bad }

fn call(): int {
    value := source() catch {
        Bad => return 0
    }
    return value
}

That program is rejected with ATOLL2010: non-exhaustive match on SourceError — missing: Other. Add the missing variant, or use _ when treating the remaining failures uniformly is intentional.

Catch versus match

Use catch when the success path should pass through unchanged and only the error side needs attention. Use match when both channels need explicit handling.

error LoadError { NotFound }

fn load_user(id: int): string ! LoadError { error NotFound }

fn show(id: int): string {
    match load_user(id) {
        Ok(user) => user
        Err(e) => "error"
    }
}

The catch subject must be a Result, including the T ! E signature spelling. catch is not an optional fallback operator — for Option, use ??, a pattern, or the Option methods:

fn first_or_zero(values: []int): int {
    return values.get(0) ?? 0
}

Evaluation

The subject is evaluated once. On Ok, the payload passes through and no arm runs. On Err, arms are tried in source order using ordinary match selection; guards may run until one succeeds, and only the selected arm body runs. Keep guards free of effects whose repetition or order would be surprising.

Cleanup registered before the subject runs still obeys the enclosing scope’s exit rules. A direct error or return from an arm is an early exit and runs the applicable defers.

catch transforms a typed Err value. It does not catch runtime traps, host termination, or arbitrary exceptions.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close