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