Skip to content

Result

Carry typed failures with Result[T, E], and transform, chain, and discharge them without panics.

Updated View as Markdown

Result[T, E] is the outcome of a fallible operation: either an Ok(T) success or an Err(E) typed failure. In function signatures it is normally spelled T ! E.

error ParseError { Empty, TooLong }

fn parse_tag(text: string): string ! ParseError {
    if text.is_empty() { error Empty }
    if text.len() > 16 { error TooLong }
    return text.to_lower_ascii()
}

fn main(): void {
    match parse_tag("Release") {
        Ok(tag) => println("tag ${tag}")
        Err(e) => println("rejected")
    }
    match parse_tag("") {
        Ok(tag) => println("tag ${tag}")
        Err(e) => println("rejected")
    }
}

fn parse_tag(text: string): string ! ParseError and fn parse_tag(text: string): Result[string, ParseError] declare the same type. The ! spelling is idiomatic in signatures; the explicit form is useful in annotations and generic positions.

error ParseError { Empty }

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

fn main(): void {
    // The same type, written both ways.
    a: Result[int, ParseError] = parse("abc")
    b := parse("abc")
    println("${a.is_ok()} ${b.is_ok()}")

    // Constructed directly.
    ok: Result[int, ParseError] = Ok(1)
    bad: Result[int, ParseError] = Err(ParseError.Empty)
    println("${ok.is_ok()} ${bad.is_err()}")
}

Like Option, every Result method is total. There is no unwrap that can abort:

error E { Bad }

fn load(): int ! E { error Bad }

fn main(): void {
    n: int = load().unwrap()
    println("${n}")
}

Inspection

error HttpError { Timeout, NotFound, Server { status: int } }

fn fetch(path: string): int ! HttpError {
    if path.is_empty() { error Timeout }
    return 200
}

fn main(): void {
    good := fetch("/health")
    bad := fetch("")

    println("${good.is_ok()} ${good.is_err()}")
    println("${bad.is_ok()} ${bad.is_err()}")

    // Predicates that only run on the matching side.
    println("${good.is_ok_and(status => status < 300)}")
    println("${bad.is_err_and(e => true)}")
}

Inspection is right for a boolean decision. When a payload is needed, match handles both channels at once, and error variants can be matched individually:

error HttpError { Timeout, NotFound }

fn fetch(path: string): int ! HttpError {
    if path.is_empty() { error Timeout }
    if path == "/missing" { error NotFound }
    return 200
}

fn report(path: string): void {
    match fetch(path) {
        Ok(status) => println("${path} -> ${status}")
        Err(HttpError.Timeout) => println("${path} timed out")
        Err(HttpError.NotFound) => println("${path} is gone")
    }
}

fn main(): void {
    report("/health")
    report("")
    report("/missing")
}

Bind the error payload as err or e. error is a keyword and can never be used as an identifier.

Variants with named fields

A variant declared with named fields is raised with the field names and matched positionally. The error statement takes Variant { field: value }; Err(...) and match patterns take Variant(binding).

error DiskError { Full { free: int }, Offline }

fn write(bytes: int): int ! DiskError {
    if bytes > 100 { error Full { free: 100 } }
    return bytes
}

fn main(): void {
    match write(500) {
        Ok(n) => println("wrote ${n}")
        Err(DiskError.Full(free)) => println("only ${free} bytes free")
        Err(DiskError.Offline) => println("offline")
    }
}

Writing Err(DiskError.Full { free: 100 }) does not work: at expression position, DiskError.Full is a constructor function, and the following brace is parsed as a separate record literal. Either raise it with error, or call the constructor positionally as DiskError.Full(100).

error DiskError { Full { free: int } }

fn write(bytes: int): int ! DiskError {
    return Err(DiskError.Full { free: 100 })
}

fn main(): void {
    println("${write(500).is_err()}")
}

Discharging a failure

unwrap_or supplies an eager fallback; unwrap_or_else receives the error and computes one.

error CacheError { Miss, Corrupt }

fn read_cache(key: string): int ! CacheError {
    if key.is_empty() { error Miss }
    return key.len()
}

fn recover(e: CacheError): int {
    return match e {
        CacheError.Miss => 0
        CacheError.Corrupt => -1
    }
}

fn main(): void {
    println("${read_cache("abc").unwrap_or(0)}")
    println("${read_cache("").unwrap_or(0)}")
    println("${read_cache("").unwrap_or_else(e => recover(e))}")
}

Both forms discard the failure, so use them only where the fallback genuinely finishes the job. Where the failure still matters, keep the Result:

Method On Ok On Err
or(other) keep the original success use other
or_else(f) keep the original success call f(err)
and(other) continue with other keep the original error
error SourceError { Unavailable }

fn primary(): int ! SourceError { error Unavailable }
fn replica(): int ! SourceError { return 7 }

fn main(): void {
    // Fall back to the replica, still as a Result.
    value: Result[int, SourceError] = primary().or(replica())
    println("${value.unwrap_or(-1)}")

    // Lazy: the replica is only consulted after a real failure.
    lazy := primary().or_else(e => replica())
    println("${lazy.unwrap_or(-1)}")
}

Transformation

map rewrites the success channel, catch_err rewrites the error channel, and flat_map chains another fallible step with the same error type.

error IoError { NotFound }
error ImportError { Unreadable, Invalid }

fn read_text(path: string): string ! IoError {
    if path.is_empty() { error NotFound }
    return "id=1"
}

fn decode(text: string): int ! ImportError {
    if !text.starts_with("id=") { error Invalid }
    return text.len()
}

fn import(path: string): int ! ImportError {
    return read_text(path)
        .catch_err(e => ImportError.Unreadable)
        .flat_map(text => decode(text))?
}

fn main(): void {
    println("${import("data.txt").unwrap_or(-1)}")
    println("${import("").unwrap_or(-1)}")
}

catch_err is the method form of the postfix catch operator: both convert Result[T, E] into Result[T, F]. Use the method in a pipeline and the operator when different variants deserve different treatment.

To remove one layer from a Result[Result[T, E], E], use flat_map with the identity callback:

error E { Bad }

fn inner(): int ! E { return 1 }

fn main(): void {
    nested: Result[Result[int, E], E] = Ok(inner())
    flat: Result[int, E] = nested.flat_map(r => r)
    println("${flat.is_ok()}")
}

The prelude also declares flatten(), but it cannot do that job. Its signature is fn flatten(self): Result[T, E], so on a Result[Result[int, E], E] the parameter T is already Result[int, E] and the result keeps the same nesting — and the method is bodyless besides, so calling it fails to build:

error E { Bad }

fn inner(): int ! E { return 1 }

fn main(): void {
    nested: Result[Result[int, E], E] = Ok(inner())
    flat: Result[int, E] = nested.flatten()   // ATOLL2002: found Result[Result[int, E], E]
    println("${flat.is_ok()}")
}

If the inner and outer error types differ, map one channel with catch_err before collapsing.

Conversion

ok() and err() each keep one channel and throw the other away.

error E { Bad }

fn step(i: int): int ! E {
    if i == 1 { error Bad }
    return i * 10
}

fn main(): void {
    results := [step(0), step(1), step(2)]

    // Keep only the successes.
    values := results.filter(r => r.is_ok()).map(r => r.unwrap_or(0))
    println("${values.len()} succeeded")

    // Count the failures without inspecting them.
    println("${results.count(r => r.is_err())} failed")

    // Single-channel views.
    first: int? = results.get(0).flat_map(r => r.ok())
    println("${first.unwrap_or(-1)}")
    println("${step(1).err().is_some()}")
}

Those conversions are for an explicit boundary — a place where the caller has decided the failure detail is not needed. They are not a substitute for propagation.

Result also declares to_list() (“one success item, or an empty list”), but it is a bodyless prelude declaration: it type-checks and then fails to build with ATOLL2004. Option.to_list() does have a body, so r.ok().to_list() is the working route.

Control flow

Most code should reach for the language constructs before the methods.

Construct Purpose
value? unwrap Ok, or return the Err from the current function
match value handle both channels locally
value catch { ... } convert or discharge the error side
error Variant leave the current fallible function with a failure
error DbError { Closed }
error ApiError { Unavailable, Internal }

fn query(sql: string): int ! DbError {
    if sql.is_empty() { error Closed }
    return sql.len()
}

fn handler(sql: string): int ! ApiError {
    // `?` propagates DbError after `catch` has converted it.
    rows := query(sql) catch {
        Closed => ApiError.Unavailable
    }?
    if rows > 1000 { error Internal }
    return rows
}

fn main(): void {
    println("${handler("select 1").unwrap_or(-1)}")
    println("${handler("").unwrap_or(-1)}")
}

? requires an enclosing fallible function. Using it in a void function is rejected:

error E { Bad }

fn load(): int ! E { error Bad }

fn main(): void {
    v := load()?
    println("${v}")
}

Collecting fallible steps is a ? loop — the first failure exits with the error, and the caller sees a single Result:

error StepError { Failed { at: int } }

fn step(i: int): int ! StepError {
    if i == 3 { error Failed { at: i } }
    return i * i
}

fn run(n: int): []int ! StepError {
    mut out: []int = []
    mut i := 0
    for i < n {
        out.add(step(i)?)
        i = i + 1
    }
    return out
}

fn main(): void {
    empty: []int = []
    println("${run(3).unwrap_or(empty).len()}")   // 3
    println("${run(5).unwrap_or(empty).len()}")   // 0 — failed at i = 3
}

Result and Option

The two types answer different questions: Option says “there is no value”, Result says “the operation failed, and here is why”. Move between them at the boundary where that distinction changes.

error LookupError { Unknown { id: int } }

fn find(id: int): string? {
    if id == 1 { return Some("ada") }
    return None
}

// Absence becomes a failure: the caller needs an explanation.
fn require(id: int): string ! LookupError {
    unknown := LookupError.Unknown { id: id }
    return find(id).to_result(unknown)?
}

// Failure becomes absence: the caller only wants the happy path.
fn maybe(id: int): string? {
    return require(id).ok()
}

fn main(): void {
    println("${maybe(1) ?? "<none>"}")
    println("${maybe(2) ?? "<none>"}")
    println("${require(2).is_err()}")
}

Equality and text

to_string() renders the active variant with its payload, and hash_code() mixes the variant with the payload’s hash, so results can appear inside derived hashable values when their component types support it.

error E { Bad }

fn load(i: int): int ! E {
    if i < 0 { error Bad }
    return i
}

fn main(): void {
    println("${load(1).to_string()}")
    println("${load(-1).to_string()}")
}

A worked example

A two-stage import that reads records, validates each one, converts the two underlying failure types into one API-level error, and reports how far it got.

error IoError { Missing { path: string } }
error ValidationError { EmptyName, BadAge { value: int } }
error ImportError { Unreadable, Rejected, Partial { at: int } }

struct Row { name: string, age: int }
struct Person { name: string, age: int }

fn read_rows(path: string): []Row ! IoError {
    if path.is_empty() { error Missing { path: path } }
    return [
        Row { name: "ada", age: 36 },
        Row { name: "grace", age: 45 },
        Row { name: "", age: 20 },
    ]
}

fn validate(row: Row): Person ! ValidationError {
    if row.name.is_empty() { error EmptyName }
    if row.age < 0 { error BadAge { value: row.age } }
    return Person { name: row.name, age: row.age }
}

fn import_people(path: string): []Person ! ImportError {
    rows := read_rows(path) catch {
        Missing { path: _ } => ImportError.Unreadable
    }?

    mut people: []Person = []
    mut index := 0
    for row in rows {
        person := validate(row) catch {
            EmptyName => { error Partial { at: index } }
            BadAge { value: _ } => { error Rejected }
        }
        people.add(person)
        index = index + 1
    }
    return people
}

fn describe(path: string): void {
    match import_people(path) {
        Ok(people) => println("${path}: imported ${people.len()}")
        Err(ImportError.Unreadable) => println("${path}: could not be read")
        Err(ImportError.Rejected) => println("${path}: rejected")
        Err(ImportError.Partial(at)) => println("${path}: stopped at row ${at}")
    }
}

fn main(): void {
    describe("people.csv")
    describe("")
}

import_people never lets IoError or ValidationError escape: each is converted at the point it arrives, so the signature []Person ! ImportError is the complete contract its callers have to handle. See Errors for the full propagation rules.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close