Skip to content

Error Types

Declare named failure domains with error, construct and match their variants, and choose between explicit and inferred fallible signatures.

Updated View as Markdown

An error declaration creates a named, closed failure domain. It behaves like an enum, with extra rules that let error, ?, and catch find its variants.

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

struct User { name: string }

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

fn main(): void {
    println("${load_user(1, true).is_ok()}")
    println("${load_user(0, true).is_ok()}")
    println("${load_user(1, false).is_ok()}")
}

Error and variant names are PascalCase; payload field names are snake_case.

Variants

A variant is either a bare name or a name with a { field: Type, ... } payload. Variants may be separated by newlines or commas, and a compact one-line declaration is fine for a small domain.

error ParseError {
    Empty
    InvalidCharacter { found: char }
    InvalidDigit { position: int, found: char }
}

error Compact { Empty, Truncated }

fn label(e: ParseError): string {
    return match e {
        Empty => "empty"
        InvalidCharacter { found } => "char ${found}"
        InvalidDigit { position, found } => "digit ${found}@${position}"
    }
}

fn main(): void {
    println(label(ParseError.Empty))
    println(label(ParseError.InvalidCharacter('x')))
    println(label(ParseError.InvalidDigit(3, 'x')))
    println("${Compact.Truncated == Compact.Truncated}")
}

Error types participate in pattern matching and exhaustiveness checking. A payload can be destructured wholly or partially, renamed with field: binding, and ignored with _:

error ParseError {
    Empty
    InvalidCharacter { found: char }
    InvalidDigit { position: int, found: char }
}

fn describe(e: ParseError): string {
    return match e {
        Empty => "empty input"
        InvalidCharacter { found } => "invalid ${found}"
        InvalidDigit { position, found } => "${found} at ${position}"
    }
}

fn position(e: ParseError): int {
    return match e {
        InvalidDigit { position: at } => at
        _ => -1
    }
}

fn main(): void {
    e := ParseError.InvalidDigit(3, 'x')
    println(describe(e))
    println("${position(e)}")
    println("${position(ParseError.Empty)}")
}

The parser also accepts a tuple-style payload (InvalidCharacter(char)), but its pattern bindings do not currently receive the declared field type. Use named fields; they read better anyway, and the positional constructor described below still lets you build one without repeating the names.

Omitting a reachable variant is an error:

error ParseError { Empty, Truncated }

fn describe(e: ParseError): string {
    return match e {
        Empty => "empty input"
    }
}

That reports ATOLL2010: non-exhaustive match on ParseError — missing: Truncated.

Constructing a variant

There are two spellings, and they are not interchangeable.

Bare, resolved by expected type. This form supports brace payloads and is what you use inside error statements and returns of the right type:

error ParseError {
    Empty
    InvalidDigit { position: int, found: char }
}

fn make(): ParseError {
    return InvalidDigit { position: 3, found: 'x' }
}

fn fail_now(): int ! ParseError {
    error Empty
}

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

Qualified and positional. ErrorType.Variant(...) works with no expected type in sight — for example when binding a value into a local first:

error ParseError {
    Empty
    InvalidDigit { position: int, found: char }
}

fn f(pick: bool): int ! ParseError {
    a := ParseError.Empty
    b := ParseError.InvalidDigit(3, 'x')
    if pick { return Err(a) }
    return Err(b)
}

fn main(): void {
    println("${f(true).is_err()} ${f(false).is_err()}")
}

The qualified path is always positional. Combining it with a brace payload does not work — the path resolves to a constructor function and the { starts a block:

error ParseError { InvalidDigit { position: int, found: char } }

fn make(): ParseError {
    return ParseError.InvalidDigit { position: 3, found: 'x' }
}

This matters most when passing an error to a method such as Option.to_result, where no expected type is available at the argument position. Bind first:

error LookupError { Missing { key: string } }

fn require(settings: Map[string, string], key: string): string ! LookupError {
    missing := LookupError.Missing(key)
    return settings.get(key).to_result(missing)?
}

fn main(): void {
    mut settings: Map[string, string]
    settings.put("host", "localhost")
    println(require(settings, "host").unwrap_or("-"))
    println(require(settings, "port").unwrap_or("-"))
}

Nominal identity

An error type is nominal. Two declarations with identical variants are still different domains, and a value from one is never accepted where the other is expected:

error ReadError { Timeout }
error WriteError { Timeout }

fn f(e: ReadError): WriteError {
    return e
}

Nothing about equal variant names or payload shapes merges the two. Conversion is explicit — a match, a catch, or a constructor call.

Do not reuse a prelude error name. IoError in particular is already declared by the file and network APIs; a local error IoError { … } type-checks, but the duplicate name currently defeats wasm lowering and the build fails with ATOLL5005. Give your domain its own name.

Signatures

T ! E declares a function that succeeds with T or fails with E. It is sugar for Result[T, E] and is valid only in a return-type position.

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

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

fn store(u: User): Result[int, LoadError] {
    return Ok(u.name.len())
}

fn main(): void {
    match load_user(1) {
        Ok(u) => println("${store(u).unwrap_or(0)}")
        Err(e) => println("not found")
    }
}

Use an explicit error type on public APIs, host boundaries, trait requirements, and any function whose failure contract should stay put when its body changes.

Error exits

Inside a fallible function, error value leaves through the error channel.

error ValidationError {
    Blank
    TooLong { limit: int }
}

fn validate(name: string): string ! ValidationError {
    if name.is_empty() {
        error Blank
    }
    if name.len() > 32 {
        error TooLong { limit: 32 }
    }
    return name
}

fn main(): void {
    println(validate("ada").unwrap_or("rejected"))
    println(validate("").unwrap_or("rejected"))
}

return Err(value) is the explicit result form and means the same thing:

error ValidationError { Blank }

fn validate(name: string): string ! ValidationError {
    if name.is_empty() {
        return Err(ValidationError.Blank)
    }
    return name
}

fn main(): void {
    println(validate("ada").unwrap_or("rejected"))
    println(validate("").unwrap_or("rejected"))
}

An error exit evaluates its payload once, then leaves through the same scope-unwinding path as an early return. Registered defers run in reverse order and managed locals are released; nothing later on that path runs.

error DeviceError { Closed }

fn write(open: bool): int ! DeviceError {
    defer { println("released") }
    if not open {
        error Closed
    }
    println("wrote")
    return 1
}

fn main(): void {
    println("${write(true).unwrap_or(0)}")
    println("${write(false).unwrap_or(0)}")
}

Using error or ? in a function whose return type is not fallible is rejected:

error DeviceError { Closed }

fn write(open: bool): int {
    if not open {
        error Closed
    }
    return 1
}

Both spellings report the same thing. The verbatim message is:

error[ATOLL3051]: function `write` uses `error`/`?` but its return type `int`
is not fallible — declare `int ! <Error>` or `Result[int, <Error>]`

A second diagnostic follows it at the offending site — ATOLL2002: error requires an enclosing fallible function for an error statement, or ATOLL2002: ? on result requires an enclosing fallible function for a ?. Fix the signature, not the body: annotate int ! DeviceError, or drop the annotation entirely and let inference supply it.

Success exits

A compatible bare return is lifted into Ok. return Ok(value) is equally valid — pick whichever exposes the local control flow better.

error LoadError { NotFound }

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

fn wrapped(id: int): int ! LoadError {
    if id < 1 { return Err(LoadError.NotFound) }
    return Ok(id)
}

fn main(): void {
    println("${lifted(4).unwrap_or(-1)} ${wrapped(0).unwrap_or(-1)}")
}

Inference

An unannotated function infers its fallibility from the error statements and propagating ? sites in its body.

error CacheError { Unavailable }
error StoreError { Disconnected }

fn read_cache(): int ! CacheError { error Unavailable }
fn read_store(): int ! StoreError { error Disconnected }

fn choose(cached: bool) {
    if cached {
        return read_cache()?
    }
    return read_store()?
}

fn main(): void {
    println("${choose(true).unwrap_or(-1)}")
    println("${choose(false).unwrap_or(-1)}")
}

choose has an int success type and an inferred error side containing both CacheError and StoreError. The union is closed over the error types observed while checking that body; it is not a dynamically extensible exception set.

The projections choose::result and choose::err name the two sides without repeating the members:

error CacheError { Unavailable }
error StoreError { Disconnected }

fn read_cache(): int ! CacheError { error Unavailable }
fn read_store(): int ! StoreError { error Disconnected }

fn choose(cached: bool) {
    if cached {
        return read_cache()?
    }
    return read_store()?
}

fn status(e: choose::err): int {
    return match e {
        Unavailable => 503
        Disconnected => 502
        _ => 500
    }
}

fn double(v: choose::result): int {
    return v * 2
}

fn main(): void {
    println("${double(21)}")
    match choose(true) {
        Ok(v) => println("succeeded")
        Err(e) => println("status ${status(e)}")
    }
}

Note the _ => 500 arm. An inferred union is unbounded from the checker’s point of view, so a match on a fn::err scrutinee needs a wildcard even when you list every variant you know about; without one the scrutinee is rejected as unbounded. A declared union has a closed variant set and is checked exhaustively instead.

See Projections.

An explicit non-fallible annotation is a constraint, not a hint — writing fn choose(cached: bool): int makes both ? sites errors. Declare int ! SomeError, remove the propagation, or leave the function unannotated.

Because inference is body-sensitive, adding a propagated call can change an internal signature. That is the main reason to declare exported error contracts explicitly.

Behaviour on an error type

Errors are ordinary values, so an impl block can attach the policy questions callers keep asking.

error WriteError {
    PermissionDenied { path: string }
    Unavailable { path: string, retry_after_ms: int? }
    InvalidData { field: string, reason: string }
}

impl WriteError {
    fn is_retryable(self): bool {
        return match self {
            Unavailable { path: _, retry_after_ms: _ } => true
            _ => false
        }
    }

    fn retry_after_ms(self): int {
        return match self {
            Unavailable { retry_after_ms } => retry_after_ms ?? 100
            _ => 0
        }
    }
}

struct Failure { at: int, cause: WriteError }

fn total_backoff(failures: []Failure): int {
    mut total := 0
    for f in failures {
        if f.cause.is_retryable() {
            total += f.cause.retry_after_ms()
        }
    }
    return total
}

fn main(): void {
    e := WriteError.Unavailable("/tmp/out", Some(250))
    println("${total_backoff([Failure { at: 1, cause: e }])}")
}

Display

No human-readable message is generated for you. Implement Display where text is needed; to_string is what "${e}" calls.

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

impl Display for LoadError {
    fn to_string(self): string {
        return match self {
            NotFound { id } => "user ${id} was not found"
            PermissionDenied => "permission denied"
            Storage { message } => message
        }
    }
}

fn main(): void {
    println("load failed: ${LoadError.NotFound(7)}")
}

Keep the structured variants for program decisions and the Display text for humans. Callers should never have to parse a message to decide what to do.

Design

Encode the category as the variant and the decision-relevant data as typed fields — WriteError above is the shape to copy:

  • keep variants actionable — a caller should be able to do something different for each one;
  • put stable identifiers and bounded summaries in payloads, not an unbounded object graph;
  • separate transport and backend detail from portable domain failures;
  • reach for an error union when a boundary really does expose several existing domains.

Atoll attaches no implicit cause chain and no stack trace. If a lower-level cause must survive, give it a typed field.

Evolution

An exported error type is compatibility surface:

  • adding a variant breaks exhaustive handlers;
  • removing or renaming one breaks construction and matching;
  • changing payload fields changes the recovery contract;
  • switching from a named error to a union exposes constituent identity;
  • changing Display text should never change a program decision.

Prefer a boundary-owned vocabulary when your dependencies are expected to change independently. Re-export a dependency’s error type only when its complete taxonomy is intentionally part of your public contract.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close