Skip to content

Result

Represent typed success and failure with Ok and Err, the T ! E signature form, matching both channels, and choosing between propagation and conversion.

Updated View as Markdown

Result[T, E] has exactly two variants: Ok(T) on success and Err(E) on failure. Like Option, it is an ordinary prelude enum with extra syntax layered on top.

error ParseError { Empty }

fn parse(text: string): int ! ParseError {
    if text.is_empty() { error Empty }
    return text.len()
}

fn main(): void {
    match parse("hello") {
        Ok(n) => println("parsed ${n}")
        Err(e) => println("failed")
    }
    match parse("") {
        Ok(n) => println("parsed ${n}")
        Err(e) => println("failed")
    }
}

This page covers what Result means in the language: how to spell it, how to build one, how to match both channels, and where the boundary between propagating and converting sits. The complete method catalogue — every signature with a worked example — lives in Standard library › Result.

Spelling the type

T ! E is the signature form of Result[T, E]. It is only valid after a function’s return type:

error LoadError { NotFound }
struct User { name: string }

fn load(id: int): User ! LoadError {
    if id < 1 { error NotFound }
    return User { name: "ada" }
}

fn main(): void {
    println("${load(1).is_ok()} ${load(0).is_ok()}")
}

Anywhere else — a local annotation, a parameter, a struct field, a generic argument — write Result[T, E]:

error LoadError { NotFound }

fn f(): int {
    outcome: int ! LoadError = Ok(1)
    return outcome.unwrap_or(0)
}
error LoadError { NotFound }

struct Attempt { outcome: Result[int, LoadError] }

fn main(): void {
    outcome: Result[int, LoadError] = Ok(1)
    failed: Result[int, LoadError] = Err(LoadError.NotFound)
    attempts: []Result[int, LoadError] = [outcome, failed]
    a := Attempt { outcome: outcome }
    println("${a.outcome.unwrap_or(0)} ${attempts.len()}")
}

Construction

Inside a fallible function you can return the bare success value — it is lifted into Ok — and use error for the failure channel. return Ok(v) and return Err(e) are also accepted when the wrapper makes the control flow clearer.

error ParseError { Empty, NotADigit { found: char } }

fn digit(c: char): int ! ParseError {
    if c < '0' or c > '9' {
        error NotADigit { found: c }
    }
    return c.to_int() - '0'.to_int()
}

fn digit_explicit(c: char): int ! ParseError {
    if c < '0' or c > '9' {
        return Err(ParseError.NotADigit(c))
    }
    return Ok(c.to_int() - '0'.to_int())
}

fn main(): void {
    println("${digit('7').unwrap_or(-1)}")
    println("${digit_explicit('x').unwrap_or(-1)}")
}

Two spellings name a variant. The bare form (NotADigit { found: c }) resolves against the expected error type and supports brace payloads. The qualified form (ParseError.NotADigit(c)) is positional and is what you need when the expected type is not available — for example when constructing a value to store in a local before passing it to a method.

ErrorType.Variant { field: value } does not work: the qualified path resolves to a positional constructor, and the following brace is parsed as a block.

error ParseError { NotADigit { found: char } }

fn f(): ParseError {
    return ParseError.NotADigit { found: 'x' }
}

Use the bare form under an expected type, or the positional constructor:

error ParseError { NotADigit { found: char } }

fn from_expected(): ParseError {
    return NotADigit { found: 'x' }
}

fn from_positional(): ParseError {
    return ParseError.NotADigit('x')
}

fn main(): void {
    r: Result[int, ParseError] = Err(from_expected())
    s: Result[int, ParseError] = Err(from_positional())
    println("${r.is_err()} ${s.is_err()}")
}

Matching

Explicit Ok / Err arms are the direct form and always work:

error LoadError { NotFound, Denied }
struct User { name: string }

fn load(id: int): User ! LoadError {
    if id < 1 { error NotFound }
    return User { name: "ada" }
}

fn show(id: int): string {
    return match load(id) {
        Ok(user) => user.name
        Err(e) => "unavailable"
    }
}

fn main(): void {
    println(show(1))
    println(show(0))
}

The error side can also be matched by variant directly, which saves a level of nesting when each failure deserves its own answer:

error LoadError { NotFound, Denied }

fn load(id: int): int ! LoadError {
    if id < 1 { error NotFound }
    if id > 100 { error Denied }
    return id
}

fn status(id: int): int {
    match load(id) {
        Ok(v) => { return 200 }
        NotFound => { return 404 }
        Denied => { return 403 }
    }
}

fn main(): void {
    println("${status(5)} ${status(0)} ${status(500)}")
}

Once you are matching the error value — after an Err(e) arm, on an error parameter, or in a catch block — the usual exhaustiveness rule applies, and omitting a variant is an ATOLL2010 error:

error LoadError { NotFound, Denied }

fn status(e: LoadError): int {
    match e {
        NotFound => { return 404 }
    }
}

That reports ATOLL2010: non-exhaustive match on LoadError — missing: Denied. Add the arm, or use _ when treating the remaining failures uniformly is intentional.

Ok(value) and Err(value) stay distinct even when T and E are the same type — the tags differ, and so does their control meaning.

Bind an error payload as err or e. error is a keyword and cannot be an identifier anywhere in Atoll, so Err(error) => … does not parse.

Two channels, one value

Every operation on a Result answers the same question — what happens to Ok, and what happens to Err:

Operation On Ok(value) On Err(e)
map(f) Ok(f(value)) pass the error through
flat_map(f) return f(value) pass the error through
catch_err(f) pass the value through Err(f(e))
and(other) return other pass the error through
or(other) pass the value through return other
or_else(f) pass the value through return f(e)
unwrap_or(d) value d
unwrap_or_else(f) value f(e)
ok() Some(value) None
err() None Some(e)
catch { … } pass the value through run the matching arm
postfix ? continue with value return Err(e) from the enclosing fn

The queries is_ok, is_err, is_ok_and, and is_err_and inspect the tag without consuming the result:

error HttpError { Timeout, Status { code: int } }

fn fetch(url: string): string ! HttpError {
    if url.is_empty() { error Timeout }
    if url == "bad" { error Status { code: 503 } }
    return "body"
}

fn retryable(url: string): bool {
    return fetch(url).is_err_and(e => match e {
        Timeout => true
        Status { code } => code >= 500
    })
}

fn main(): void {
    println("${retryable("")} ${retryable("bad")} ${retryable("ok")}")
    println("${fetch("ok").is_ok_and(body => body.len() > 0)}")
}

map rewrites Ok and leaves Err alone. flat_map sequences a second fallible step that shares the same error type, without producing a nested result.

error ImportError { Empty, TooLong }
struct Record { id: int, label: string }

fn parse(line: string): Record ! ImportError {
    if line.is_empty() { error Empty }
    return Record { id: line.len(), label: line }
}

fn check(r: Record): Record ! ImportError {
    if r.label.len() > 64 { error TooLong }
    return r
}

fn label_of(line: string): Result[string, ImportError] {
    return parse(line).map(r => r.label)
}

fn validated(line: string): Result[Record, ImportError] {
    return parse(line).flat_map(r => check(r))
}

fn main(): void {
    println(label_of("hello").unwrap_or("-"))
    println(label_of("").unwrap_or("-"))
    println("${validated("hello").is_ok()}")
}

Evaluation is what separates the eager forms from the lazy ones:

error FeedError { Down }

fn primary(): int ! FeedError { error Down }
fn secondary(): int ! FeedError { return 7 }

fn main(): void {
    // secondary() runs even when primary() succeeds
    eager := primary().or(secondary())
    // secondary() runs only on the Err path
    lazy := primary().or_else(e => secondary())
    println("${eager.unwrap_or(0)} ${lazy.unwrap_or(0)}")
}

unwrap_or(value), or(result), and and(result) receive an already-evaluated argument; unwrap_or_else, or_else, map, and flat_map invoke their callback only in the case that needs it. There is no panicking unwrap in Atoll — every one of these is total.

Two prelude declarations on Result are not implementable today: to_list(self): List[T] and flatten(self): Result[T, E] have no lowering path, so a program that calls either type-checks but fails to build with ATOLL2004. Use ok().to_list() and a match respectively until they land. Option.to_list and Option.flatten do work.

Transforming the error side

catch_err maps the error type with a closure. The catch block form does the same thing with exhaustive patterns, and is usually clearer — see Catch.

error StorageError { Disk, Network }
error AppError { Unavailable }

fn read(): int ! StorageError { error Disk }

fn with_method(): Result[int, AppError] {
    return read().catch_err(e => AppError.Unavailable)
}

fn with_catch(): Result[int, AppError] {
    return read() catch {
        Disk => AppError.Unavailable
        Network => AppError.Unavailable
    }
}

fn main(): void {
    println("${with_method().is_err()} ${with_catch().is_err()}")
}

Both produce Result[int, AppError] — a catch whose arms yield values rewrites the error channel; it does not unwrap the result.

Converting to an option

ok() discards the error and yields an Option[T]; err() discards the success and yields an Option[E].

error LoadError { NotFound }

fn load(id: int): int ! LoadError {
    if id < 1 { error NotFound }
    return id
}

fn main(): void {
    maybe := load(3).ok()       // int?
    failure := load(0).err()    // LoadError?
    println("${maybe.unwrap_or(0)} ${failure.is_some()}")
}

Use ok() only where the reason for the failure is genuinely irrelevant. Converting a result to an option before logging, retry, or policy code has seen the error throws away the only thing that could have driven a decision.

Going the other way, Option.to_result(err) names the failure that absence means at this boundary — see Option.

Propagation

Postfix ? produces the Ok payload and returns the Err from the enclosing fallible function.

error LoadError { NotFound }
struct User { name: string, team: int }

fn load_user(id: int): User ! LoadError {
    if id < 1 { error NotFound }
    return User { name: "ada", team: 2 }
}

fn load_team(id: int): string ! LoadError {
    if id < 1 { error NotFound }
    return "core"
}

fn summary(id: int): string ! LoadError {
    user := load_user(id)?
    team := load_team(user.team)?
    return "${user.name} / ${team}"
}

fn main(): void {
    println(summary(1).unwrap_or("unavailable"))
    println(summary(0).unwrap_or("unavailable"))
}

Beware that ?. is a single token. load_user(id)?.name is parsed as optional member access on a Result, not propagation followed by a field read:

error LoadError { NotFound }
struct User { name: string }

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

fn name_of(id: int): string ! LoadError {
    return load_user(id)?.name
}

Separate them with parentheses, whitespace, or a binding:

error LoadError { NotFound }
struct User { name: string }

fn load_user(id: int): User ! LoadError {
    if id < 1 { error NotFound }
    return User { name: "ada" }
}

fn parenthesised(id: int): string ! LoadError {
    return (load_user(id)?).name
}

fn spaced(id: int): string ! LoadError {
    return load_user(id)? .name
}

fn bound(id: int): string ! LoadError {
    user := load_user(id)?
    return user.name
}

fn main(): void {
    println(parenthesised(1).unwrap_or("-"))
    println(spaced(1).unwrap_or("-"))
    println(bound(0).unwrap_or("-"))
}

See Propagation for the compatibility rules between the propagated error and the enclosing signature.

A composed example

An import pipeline puts every piece together: two error domains, a union at the boundary, propagation through the middle, and one exhaustive report at the top.

error ValidationError {
    TooShort { min: int }
    TooLong { max: int }
}

error SaveError {
    Disk { message: string }
}

error PipelineError = ValidationError | SaveError

fn validate(name: string): string ! ValidationError {
    if name.len() < 3 { error TooShort { min: 3 } }
    if name.len() > 32 { error TooLong { max: 32 } }
    return name
}

fn save(name: string): int ! SaveError {
    if name == "boom" { error Disk { message: "device full" } }
    return name.len()
}

fn run(name: string): int ! PipelineError {
    checked := validate(name)?
    return save(checked)?
}

fn report(name: string): string {
    match run(name) {
        Ok(n) => { return "stored ${n} bytes" }
        Err(e) => {
            return match e {
                v: ValidationError => match v {
                    TooShort { min } => "need at least ${min} characters"
                    TooLong { max } => "at most ${max} characters"
                }
                s: SaveError => match s {
                    Disk { message } => "storage failure: ${message}"
                }
            }
        }
    }
}

fn main(): void {
    println(report("ada"))
    println(report("no"))
    println(report("boom"))
}

The inner arms use binding: MemberError to split the union by constituent first, then match that member’s own variants. That is the shape to use whenever union members carry payloads — see Error Unions.

run never inspects a failure; it only widens two domains into one. report is the single place that decides what each failure means to a user.

Handling discipline

A Result is an ordinary value. It can be stored in a struct, put in a list, passed to a function, returned, or deliberately dropped. The language does not turn it into an exception or a hidden control channel, so an accidentally discarded result is a review question rather than a compiler one.

Keep each operation at the layer that can make the decision:

  • map for a local success-only transformation;
  • flat_map to sequence fallible work without nesting results;
  • catch where recovery or domain conversion belongs;
  • ? where this function intentionally exposes the same error contract;
  • match when both channels drive explicit local behaviour.
Navigation

Type to search…

↑↓ navigate↵ selectEsc close