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 | IoErrorUse 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.