Skip to content

Error Unions

Combine several declared error domains into one boundary type without wrapper variants.

Updated View as Markdown

An error union names the failure domains a boundary exposes, without wrapping any of them in a new variant.

error AuthError { InvalidToken, Expired }
error DbError { ConnectionFailed }

error AppError = AuthError | DbError

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

fn load(user: int): string ! DbError {
    if user < 1 { error ConnectionFailed }
    return "row"
}

fn handle(token: string): string ! AppError {
    user := authenticate(token)?
    return load(user)?
}

handle never mentions AuthError.InvalidToken or DbError.ConnectionFailed. It only declares that both domains can reach its caller, and ? does the widening.

Widening is what ? does

A member error flows into its union through propagation. There is no wrapper variant and no conversion call.

error AuthError { InvalidToken }
error DbError { ConnectionFailed }
error AppError = AuthError | DbError

fn authenticate(): int ! AuthError {
    error InvalidToken
}

fn handle(): int ! AppError {
    return authenticate()?
}

You cannot raise a member’s variant directly in a union-returning function, though — the union is the declared error type, and error InvalidToken produces an AuthError:

error AuthError { InvalidToken }
error DbError { ConnectionFailed }
error AppError = AuthError | DbError

fn handle(): int ! AppError {
    error InvalidToken
}

That reports ATOLL2002: expected AppError, found AuthError. The union type itself can name a bare variant, which covers the occasional direct exit:

error AuthError { InvalidToken }
error DbError { ConnectionFailed }
error AppError = AuthError | DbError

fn handle(admin: bool): int ! AppError {
    if not admin { error AppError.InvalidToken }
    return 1
}

Otherwise, keep the raise in a function that returns the member type and let ? widen it. That is the shape the union is designed for.

Flattening

Unions nest and flatten. The outermost declaration contains the variants of every constituent, and a value from any of them propagates the whole way up:

error AuthError { InvalidToken }
error DbError { ConnectionFailed }
error NetworkError { Timeout }

error ServiceError = AuthError | DbError
error AppError = ServiceError | NetworkError

fn authenticate(): int ! AuthError { error InvalidToken }
fn service(): int ! ServiceError { return authenticate()? }
fn app(): int ! AppError { return service()? }

fn direct(): int ! AppError { return authenticate()? }

fn status(e: AppError): int {
    return match e {
        InvalidToken => 401
        ConnectionFailed => 503
        Timeout => 504
    }
}

Flattening removes nested union layers. It does not merge two declarations just because their variants have the same names and payloads — each constituent keeps its own identity.

Matching a union

A match over a union must cover every reachable member variant.

error AuthError { InvalidToken, Expired }
error DbError { ConnectionFailed }
error AppError = AuthError | DbError

fn status(e: AppError): int {
    return match e {
        InvalidToken => 401
    }
}

The message lists the complete flattened set: ATOLL2010: non-exhaustive match on AppError — missing: AuthError.Expired, DbError.ConnectionFailed.

Bare variant names work for unit variants, as in status above. When a variant carries a payload, split by constituent first with a binding: MemberError arm, then match that member’s own variants:

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

error SaveError {
    Disk { message: string }
}

error PipelineError = ValidationError | SaveError

fn describe(e: PipelineError): string {
    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}"
        }
    }
}

A payload-carrying variant named bare at the union level is not recognised as covering that variant:

error ValidationError { TooShort { min: int } }
error SaveError { Disk { message: string } }
error PipelineError = ValidationError | SaveError

fn describe(e: PipelineError): string {
    return match e {
        TooShort { min } => "too short: ${min}"
        Disk { message } => message
    }
}

Type-group arms

binding: ErrorType matches every variant belonging to that constituent, which is exactly what you want when a whole subsystem gets one handler:

error AuthError { InvalidToken, Expired, Revoked }
error DbError { ConnectionFailed, Deadlock }
error AppError = AuthError | DbError

fn handle_auth(e: AuthError): int { return 401 }
fn handle_database(e: DbError): int { return 503 }

fn status(e: AppError): int {
    return match e {
        auth: AuthError => handle_auth(auth)
        database: DbError => handle_database(database)
    }
}

Type-group coverage is constituent-based, so once an arm covers AuthError, later arms for individual AuthError variants are unreachable. Group arms mix freely with individual variant arms and a trailing _:

error AuthError { InvalidToken, Expired }
error DbError { ConnectionFailed }
error AppError = AuthError | DbError

fn status(e: AppError): int {
    return match e {
        auth: AuthError => 401
        ConnectionFailed => 503
    }
}

fn coarse(e: AppError): int {
    return match e {
        ConnectionFailed => 503
        _ => 500
    }
}

Ambiguity

If two members define the same variant name, a bare pattern cannot say which one it means:

error BookingError { Timeout }
error QueryError { Timeout }
error ServiceError = BookingError | QueryError

fn retry(e: ServiceError): int {
    return match e {
        Timeout => 1
    }
}

The diagnostic is ATOLL2011: ambiguous variant Timeout — exists in BookingError, QueryError. Qualify each one:

error BookingError { Timeout }
error QueryError { Timeout }
error ServiceError = BookingError | QueryError

fn retry_booking(): int { return 1 }
fn retry_query(): int { return 2 }

fn retry(e: ServiceError): int {
    return match e {
        BookingError.Timeout => retry_booking()
        QueryError.Timeout => retry_query()
    }
}

Qualification preserves constituent identity even when the names and payloads coincide. Type-group arms are an equally good answer here, and read better when each subsystem has its own retry policy.

Inferred unions

An unannotated function that propagates several error types receives a synthetic union.

error AuthError { InvalidToken }
error DbError { ConnectionFailed }

fn authenticate(): int ! AuthError { error InvalidToken }
fn load_data(session: int): string ! DbError { error ConnectionFailed }

fn refresh() {
    session := authenticate()?
    return load_data(session)?
}

fn caller(): string {
    return refresh().unwrap_or("stale")
}

refresh has a string success type and an error side containing both AuthError and DbError. Callers can propagate it, catch it, or name it as refresh::err.

An inferred union is unbounded from the checker’s point of view, so a match over refresh::err needs a wildcard arm even when you list every variant you know about:

error AuthError { InvalidToken }
error DbError { ConnectionFailed }

fn authenticate(): int ! AuthError { error InvalidToken }
fn load_data(session: int): string ! DbError { error ConnectionFailed }

fn refresh() {
    session := authenticate()?
    return load_data(session)?
}

fn status(e: refresh::err): int {
    return match e {
        InvalidToken => 401
        ConnectionFailed => 503
        _ => 500
    }
}

Inference is body-sensitive: adding one propagated call enlarges the union. Declare the union at a public boundary so the API changes only when the declaration does.

Narrowing

Widening is directional. A member flows into its union; the union does not flow back into a member. Narrowing needs logic that handles every other constituent, which catch with type-group arms expresses compactly:

error AuthError { InvalidToken, Expired }
error DbError { ConnectionFailed }
error AppError = AuthError | DbError

fn authenticate(): int ! AuthError { error InvalidToken }
fn load(user: int): int ! DbError { error ConnectionFailed }

fn app(): int ! AppError {
    user := authenticate()?
    return load(user)?
}

fn auth_only(): int ! AuthError {
    return app() catch {
        auth: AuthError => auth
        db: DbError => AuthError.Expired
    }?
}

The db arm is where the information loss happens, and making it explicit is the point: a connection failure genuinely is not an authentication failure, so the boundary has to decide what to call it.

A composed example

Two subsystems, one union at the request boundary, and a single place that maps failures to status codes.

error AuthError {
    InvalidToken
    Expired { at: int }
}

error StoreError {
    NotFound { id: int }
    Unavailable { retry_after_ms: int }
}

error RequestError = AuthError | StoreError

struct Document { id: int, body: string }

fn session_for(token: string): int ! AuthError {
    if token.is_empty() { error InvalidToken }
    if token == "old" { error Expired { at: 1700000000 } }
    return 42
}

fn fetch(session: int, id: int): Document ! StoreError {
    if session == 0 { error Unavailable { retry_after_ms: 250 } }
    if id < 1 { error NotFound { id: id } }
    return Document { id: id, body: "hello" }
}

fn read(token: string, id: int): Document ! RequestError {
    session := session_for(token)?
    return fetch(session, id)?
}

fn status_code(e: RequestError): int {
    return match e {
        a: AuthError => match a {
            InvalidToken => 401
            Expired { at: _ } => 401
        }
        s: StoreError => match s {
            NotFound { id: _ } => 404
            Unavailable { retry_after_ms: _ } => 503
        }
    }
}

fn main(): void {
    match read("old", 1) {
        Ok(doc) => println(doc.body)
        Err(e) => println("HTTP ${status_code(e)}")
    }
}

read is pure plumbing — it widens and propagates. status_code is the one function that has to change when either subsystem grows a variant, and the checker will point at it.

Union or wrapper?

Use a union when the members are intentionally part of the public contract:

error ParseError { Malformed { line: int } }
error ResolveError { Unknown { name: string } }
error IoError { Unreadable { path: string } }

error ImportError = ParseError | ResolveError | IoError

Use a wrapper declaration when the boundary should stay stable while its dependencies change:

error ImportError {
    InvalidSource { message: string }
    Unavailable { message: string }
}

The wrapper costs an explicit catch at every conversion point, but it stops a storage or transport taxonomy from silently becoming your application’s API. Unions preserve constituent detail — useful internally, and a liability when a dependency adding a failure case forces every exhaustive caller to update.

Union declaration order is not a stable tag or serialization contract. If errors cross a wire, persistence, or FFI boundary, define an explicit encoding and an unknown-case policy rather than deriving numbers from member order.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close