Skip to content

Errors

How Atoll models absence and failure as explicit values, and how to choose between Option, Result, error types, and unions.

Updated View as Markdown

Atoll has no null, no exceptions, and no throw. Absence and failure are ordinary values with ordinary types, and the checker makes you deal with them.

error LoadError { NotFound { id: int } }

struct User { id: int, name: string, email: string? }

fn load(users: []User, id: int): User ! LoadError {
    for u in users {
        if u.id == id { return u }
    }
    error NotFound { id: id }
}

fn main(): void {
    users := [User { id: 1, name: "ada", email: None }]
    match load(users, 7) {
        Ok(user) => println(user.email ?? user.name)
        Err(e) => println("no such user")
    }
}

Two prelude enums carry everything:

  • Option[T], spelled T?, is Some(T) or None;
  • Result[T, E], spelled T ! E in a return type, is Ok(T) or Err(E).

Both are real enums, so match, exhaustiveness, and pattern binding work on them exactly as on a type you declare yourself. On top of that the language adds ?, ??, ?., catch, and error, which are just shorthand for control flow you could write by hand.

Chapters

  • Option — absence, construction, patterns, safe access, fallback, and the total method surface.
  • Result — typed success and failure, T ! E, matching, transformation, and conversion.
  • Error Types — error declarations, variant payloads, error exits, and inferred fallibility.
  • Propagation — postfix ?, where it is legal, and how it interacts with defers.
  • Catch — exhaustively convert or discharge the error side of a Result.
  • Error Unions — combine several declared domains at one boundary.

Nothing panics

There is no unwrap() or expect() in the core prelude. Every accessor is total, so there is no way to turn “I did not handle this” into a runtime abort. A program handles a value in one of five ways:

error LoadError { NotFound }

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

// 1. Match every case.
fn by_match(id: int): string {
    return match load(id) {
        Ok(v) => "got ${v}"
        Err(e) => "failed"
    }
}

// 2. Use a total fallback.
fn by_fallback(id: int): int {
    return load(id).unwrap_or(0)
}

// 3. Transform the successful payload and keep the carrier.
fn by_transform(id: int): Result[string, LoadError] {
    return load(id).map(v => "id=${v}")
}

// 4. Convert the failure into a different domain.
fn by_conversion(id: int): int {
    return load(id) catch {
        NotFound => return -1
    }
}

// 5. Propagate through a compatible boundary.
fn by_propagation(id: int): int ! LoadError {
    return load(id)? + 1
}

Failure behaviour is therefore part of ordinary control flow and of static API design, not of a separate exception mechanism.

Choosing a carrier

The decision is about meaning, not convenience.

Use Option[T] when absence is legitimate data and no explanation is needed — a cache miss, an unset field, an empty list’s first element:

struct Settings { host: string, tls_cert: string? }

fn cert_or_default(s: Settings): string {
    return s.tls_cert ?? "self-signed"
}

Use Result[T, E] when the caller needs to know why it failed, and when different reasons deserve different responses:

error UploadError {
    TooLarge { limit: int }
    Unsupported { kind: string }
    Offline
}

fn upload(size: int, kind: string): int ! UploadError {
    if size > 1000 { error TooLarge { limit: 1000 } }
    if kind != "png" { error Unsupported { kind: kind } }
    return size
}

fn retry_after(e: UploadError): int {
    return match e {
        Offline => 5000
        _ => 0
    }
}

Do not reach for Option merely to avoid declaring an error type. If callers must distinguish invalid input, permission denial, timeout, and missing data, that distinction belongs in E.

Crossing between the two is explicit in both directions:

error LookupError { Missing { key: string } }

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

fn best_effort(settings: Map[string, string], key: string): string? {
    // failure becomes absence — the reason is deliberately discarded
    return require(settings, key).ok()
}

Layering

Translate errors where the lower-level domain stops being useful. catch rewrites the error channel; adding ? propagates the rewritten value in the same expression.

error StorageError {
    Disk { path: string }
    Corrupt { path: string }
}

error DashboardError {
    Unavailable
    Internal
}

struct Dashboard { widgets: int }

fn read_blob(path: string): int ! StorageError {
    if path.is_empty() { error Disk { path: path } }
    return path.len()
}

fn load_dashboard(path: string): Dashboard ! DashboardError {
    size := read_blob(path) catch {
        Disk { path: _ } => DashboardError.Unavailable
        Corrupt { path: _ } => DashboardError.Internal
    }?
    return Dashboard { widgets: size }
}

Propagate when the current API intentionally exposes the same domain. Convert when callers should depend on this layer’s stable vocabulary instead of storage, network, or host implementation details. An error union is the third option: it exposes several existing domains without inventing wrapper variants.

Boundaries are compatibility surface

Public functions should state stable success and error types. Internal functions can infer fallibility from their error and ? sites, but inference is body-sensitive — one new propagated call can widen an inferred signature.

error CacheError { Cold }
error StoreError { Down }

fn from_cache(): int ! CacheError { error Cold }
fn from_store(): int ! StoreError { error Down }

// Inferred: int, with an error side containing both domains.
fn read(prefer_cache: bool) {
    if prefer_cache {
        return from_cache()?
    }
    return from_store()?
}

fn caller(): int {
    return read(true).unwrap_or(-1)
}

For an exported error type, remember that:

  • adding a variant breaks exhaustive handlers;
  • removing or renaming one breaks construction and matching;
  • changing payload fields changes the recovery contract;
  • changing Display text should never change a program decision.

Reviewing a failure path

For each fallible operation, these questions should have answers you can point at in the code:

Stage Question
Origin Which typed variant describes the failure?
Context Which stable identifiers must the payload preserve?
Propagation Is this function intentionally exposing the same domain?
Conversion Where does an implementation error become an application error?
Recovery Which variants can this layer actually correct?
Observation Where is an unrecovered failure logged or supervised?
Cleanup Which scopes and resources are released as the error travels?

Recovery means this layer can produce a valid success or a more appropriate typed outcome. Logging an error and returning it is observation plus propagation, not recovery. Retrying without a bound or an idempotency argument is not a complete policy either.

Everything together

A small request handler exercises absence, a typed domain, conversion at the boundary, and one exhaustive report.

struct User { id: int, name: string, email: string? }

error UserError {
    NotFound { id: int }
    PermissionDenied
}

error RequestError {
    Unauthorized
    Missing { id: int }
}

fn find(users: []User, id: int): User? {
    for u in users {
        if u.id == id { return Some(u) }
    }
    return None
}

fn load_user(users: []User, id: int, admin: bool): User ! UserError {
    if not admin { error PermissionDenied }
    missing := UserError.NotFound(id)
    return find(users, id).to_result(missing)?
}

fn contact(users: []User, id: int, admin: bool): string ! RequestError {
    user := load_user(users, id, admin) catch {
        NotFound { id: missing } => RequestError.Missing(missing)
        PermissionDenied => RequestError.Unauthorized
    }?
    return user.email ?? user.name
}

fn main(): void {
    users := [
        User { id: 1, name: "ada", email: Some("[email protected]") },
        User { id: 2, name: "grace", email: None },
    ]
    for id in [1, 2, 3] {
        match contact(users, id, true) {
            Ok(where_to) => println("${id}: ${where_to}")
            Err(e) => println("${id}: unavailable")
        }
    }
}

find returns an Option because a missing id is not, by itself, an error. load_user decides that at its boundary it is, and names it NotFound. contact translates the storage vocabulary into a request vocabulary. Each transition is one line, and each one is visible.

Logging, retries, HTTP status mapping, and process supervision are not language semantics. They belong to the host or the application layer, built on top of the typed values described here.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close