Result[T, E] has exactly two variants: Ok(T) on success and Err(E) on
failure. Like Option, it is an ordinary prelude enum with extra syntax layered
on top.
error ParseError { Empty }
fn parse(text: string): int ! ParseError {
if text.is_empty() { error Empty }
return text.len()
}
fn main(): void {
match parse("hello") {
Ok(n) => println("parsed ${n}")
Err(e) => println("failed")
}
match parse("") {
Ok(n) => println("parsed ${n}")
Err(e) => println("failed")
}
}This page covers what Result means in the language: how to spell it, how to
build one, how to match both channels, and where the boundary between
propagating and converting sits. The complete method catalogue — every
signature with a worked example — lives in
Standard library › Result.
Spelling the type
T ! E is the signature form of Result[T, E]. It is only valid after a
function’s return type:
error LoadError { NotFound }
struct User { name: string }
fn load(id: int): User ! LoadError {
if id < 1 { error NotFound }
return User { name: "ada" }
}
fn main(): void {
println("${load(1).is_ok()} ${load(0).is_ok()}")
}Anywhere else — a local annotation, a parameter, a struct field, a generic
argument — write Result[T, E]:
error LoadError { NotFound }
fn f(): int {
outcome: int ! LoadError = Ok(1)
return outcome.unwrap_or(0)
}error LoadError { NotFound }
struct Attempt { outcome: Result[int, LoadError] }
fn main(): void {
outcome: Result[int, LoadError] = Ok(1)
failed: Result[int, LoadError] = Err(LoadError.NotFound)
attempts: []Result[int, LoadError] = [outcome, failed]
a := Attempt { outcome: outcome }
println("${a.outcome.unwrap_or(0)} ${attempts.len()}")
}Construction
Inside a fallible function you can return the bare success value — it is lifted
into Ok — and use error for the failure channel. return Ok(v) and
return Err(e) are also accepted when the wrapper makes the control flow
clearer.
error ParseError { Empty, NotADigit { found: char } }
fn digit(c: char): int ! ParseError {
if c < '0' or c > '9' {
error NotADigit { found: c }
}
return c.to_int() - '0'.to_int()
}
fn digit_explicit(c: char): int ! ParseError {
if c < '0' or c > '9' {
return Err(ParseError.NotADigit(c))
}
return Ok(c.to_int() - '0'.to_int())
}
fn main(): void {
println("${digit('7').unwrap_or(-1)}")
println("${digit_explicit('x').unwrap_or(-1)}")
}Two spellings name a variant. The bare form (NotADigit { found: c }) resolves
against the expected error type and supports brace payloads. The qualified form
(ParseError.NotADigit(c)) is positional and is what you need when the expected
type is not available — for example when constructing a value to store in a
local before passing it to a method.
ErrorType.Variant { field: value } does not work: the qualified path
resolves to a positional constructor, and the following brace is parsed as a
block.
error ParseError { NotADigit { found: char } }
fn f(): ParseError {
return ParseError.NotADigit { found: 'x' }
}Use the bare form under an expected type, or the positional constructor:
error ParseError { NotADigit { found: char } }
fn from_expected(): ParseError {
return NotADigit { found: 'x' }
}
fn from_positional(): ParseError {
return ParseError.NotADigit('x')
}
fn main(): void {
r: Result[int, ParseError] = Err(from_expected())
s: Result[int, ParseError] = Err(from_positional())
println("${r.is_err()} ${s.is_err()}")
}Matching
Explicit Ok / Err arms are the direct form and always work:
error LoadError { NotFound, Denied }
struct User { name: string }
fn load(id: int): User ! LoadError {
if id < 1 { error NotFound }
return User { name: "ada" }
}
fn show(id: int): string {
return match load(id) {
Ok(user) => user.name
Err(e) => "unavailable"
}
}
fn main(): void {
println(show(1))
println(show(0))
}The error side can also be matched by variant directly, which saves a level of nesting when each failure deserves its own answer:
error LoadError { NotFound, Denied }
fn load(id: int): int ! LoadError {
if id < 1 { error NotFound }
if id > 100 { error Denied }
return id
}
fn status(id: int): int {
match load(id) {
Ok(v) => { return 200 }
NotFound => { return 404 }
Denied => { return 403 }
}
}
fn main(): void {
println("${status(5)} ${status(0)} ${status(500)}")
}Once you are matching the error value — after an Err(e) arm, on an error
parameter, or in a catch block — the usual
exhaustiveness rule applies, and omitting a variant is an ATOLL2010 error:
error LoadError { NotFound, Denied }
fn status(e: LoadError): int {
match e {
NotFound => { return 404 }
}
}That reports ATOLL2010: non-exhaustive match on LoadError — missing: Denied.
Add the arm, or use _ when treating the remaining failures uniformly is
intentional.
Ok(value) and Err(value) stay distinct even when T and E are the same
type — the tags differ, and so does their control meaning.
Bind an error payload as err or e. error is a keyword and cannot be an
identifier anywhere in Atoll, so Err(error) => … does not parse.
Two channels, one value
Every operation on a Result answers the same question — what happens to Ok,
and what happens to Err:
| Operation | On Ok(value) |
On Err(e) |
|---|---|---|
map(f) |
Ok(f(value)) |
pass the error through |
flat_map(f) |
return f(value) |
pass the error through |
catch_err(f) |
pass the value through | Err(f(e)) |
and(other) |
return other |
pass the error through |
or(other) |
pass the value through | return other |
or_else(f) |
pass the value through | return f(e) |
unwrap_or(d) |
value |
d |
unwrap_or_else(f) |
value |
f(e) |
ok() |
Some(value) |
None |
err() |
None |
Some(e) |
catch { … } |
pass the value through | run the matching arm |
postfix ? |
continue with value |
return Err(e) from the enclosing fn |
The queries is_ok, is_err, is_ok_and, and is_err_and inspect the tag
without consuming the result:
error HttpError { Timeout, Status { code: int } }
fn fetch(url: string): string ! HttpError {
if url.is_empty() { error Timeout }
if url == "bad" { error Status { code: 503 } }
return "body"
}
fn retryable(url: string): bool {
return fetch(url).is_err_and(e => match e {
Timeout => true
Status { code } => code >= 500
})
}
fn main(): void {
println("${retryable("")} ${retryable("bad")} ${retryable("ok")}")
println("${fetch("ok").is_ok_and(body => body.len() > 0)}")
}map rewrites Ok and leaves Err alone. flat_map sequences a second
fallible step that shares the same error type, without producing a nested
result.
error ImportError { Empty, TooLong }
struct Record { id: int, label: string }
fn parse(line: string): Record ! ImportError {
if line.is_empty() { error Empty }
return Record { id: line.len(), label: line }
}
fn check(r: Record): Record ! ImportError {
if r.label.len() > 64 { error TooLong }
return r
}
fn label_of(line: string): Result[string, ImportError] {
return parse(line).map(r => r.label)
}
fn validated(line: string): Result[Record, ImportError] {
return parse(line).flat_map(r => check(r))
}
fn main(): void {
println(label_of("hello").unwrap_or("-"))
println(label_of("").unwrap_or("-"))
println("${validated("hello").is_ok()}")
}Evaluation is what separates the eager forms from the lazy ones:
error FeedError { Down }
fn primary(): int ! FeedError { error Down }
fn secondary(): int ! FeedError { return 7 }
fn main(): void {
// secondary() runs even when primary() succeeds
eager := primary().or(secondary())
// secondary() runs only on the Err path
lazy := primary().or_else(e => secondary())
println("${eager.unwrap_or(0)} ${lazy.unwrap_or(0)}")
}unwrap_or(value), or(result), and and(result) receive an already-evaluated
argument; unwrap_or_else, or_else, map, and flat_map invoke their
callback only in the case that needs it. There is no panicking unwrap in
Atoll — every one of these is total.
Two prelude declarations on Result are not implementable today:
to_list(self): List[T] and flatten(self): Result[T, E] have no lowering
path, so a program that calls either type-checks but fails to build with
ATOLL2004. Use ok().to_list() and a match respectively until they land.
Option.to_list and Option.flatten do work.
Transforming the error side
catch_err maps the error type with a closure. The catch block form does the
same thing with exhaustive patterns, and is usually clearer — see
Catch.
error StorageError { Disk, Network }
error AppError { Unavailable }
fn read(): int ! StorageError { error Disk }
fn with_method(): Result[int, AppError] {
return read().catch_err(e => AppError.Unavailable)
}
fn with_catch(): Result[int, AppError] {
return read() catch {
Disk => AppError.Unavailable
Network => AppError.Unavailable
}
}
fn main(): void {
println("${with_method().is_err()} ${with_catch().is_err()}")
}Both produce Result[int, AppError] — a catch whose arms yield values
rewrites the error channel; it does not unwrap the result.
Converting to an option
ok() discards the error and yields an Option[T]; err() discards the
success and yields an Option[E].
error LoadError { NotFound }
fn load(id: int): int ! LoadError {
if id < 1 { error NotFound }
return id
}
fn main(): void {
maybe := load(3).ok() // int?
failure := load(0).err() // LoadError?
println("${maybe.unwrap_or(0)} ${failure.is_some()}")
}Use ok() only where the reason for the failure is genuinely irrelevant.
Converting a result to an option before logging, retry, or policy code has seen
the error throws away the only thing that could have driven a decision.
Going the other way, Option.to_result(err) names the failure that absence
means at this boundary — see Option.
Propagation
Postfix ? produces the Ok payload and returns the Err from the enclosing
fallible function.
error LoadError { NotFound }
struct User { name: string, team: int }
fn load_user(id: int): User ! LoadError {
if id < 1 { error NotFound }
return User { name: "ada", team: 2 }
}
fn load_team(id: int): string ! LoadError {
if id < 1 { error NotFound }
return "core"
}
fn summary(id: int): string ! LoadError {
user := load_user(id)?
team := load_team(user.team)?
return "${user.name} / ${team}"
}
fn main(): void {
println(summary(1).unwrap_or("unavailable"))
println(summary(0).unwrap_or("unavailable"))
}Beware that ?. is a single token. load_user(id)?.name is parsed as optional
member access on a Result, not propagation followed by a field read:
error LoadError { NotFound }
struct User { name: string }
fn load_user(id: int): User ! LoadError { error NotFound }
fn name_of(id: int): string ! LoadError {
return load_user(id)?.name
}Separate them with parentheses, whitespace, or a binding:
error LoadError { NotFound }
struct User { name: string }
fn load_user(id: int): User ! LoadError {
if id < 1 { error NotFound }
return User { name: "ada" }
}
fn parenthesised(id: int): string ! LoadError {
return (load_user(id)?).name
}
fn spaced(id: int): string ! LoadError {
return load_user(id)? .name
}
fn bound(id: int): string ! LoadError {
user := load_user(id)?
return user.name
}
fn main(): void {
println(parenthesised(1).unwrap_or("-"))
println(spaced(1).unwrap_or("-"))
println(bound(0).unwrap_or("-"))
}See Propagation for the compatibility rules between the propagated error and the enclosing signature.
A composed example
An import pipeline puts every piece together: two error domains, a union at the boundary, propagation through the middle, and one exhaustive report at the top.
error ValidationError {
TooShort { min: int }
TooLong { max: int }
}
error SaveError {
Disk { message: string }
}
error PipelineError = ValidationError | SaveError
fn validate(name: string): string ! ValidationError {
if name.len() < 3 { error TooShort { min: 3 } }
if name.len() > 32 { error TooLong { max: 32 } }
return name
}
fn save(name: string): int ! SaveError {
if name == "boom" { error Disk { message: "device full" } }
return name.len()
}
fn run(name: string): int ! PipelineError {
checked := validate(name)?
return save(checked)?
}
fn report(name: string): string {
match run(name) {
Ok(n) => { return "stored ${n} bytes" }
Err(e) => {
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}"
}
}
}
}
}
fn main(): void {
println(report("ada"))
println(report("no"))
println(report("boom"))
}The inner arms use binding: MemberError to split the union by constituent
first, then match that member’s own variants. That is the shape to use whenever
union members carry payloads — see
Error Unions.
run never inspects a failure; it only widens two domains into one. report
is the single place that decides what each failure means to a user.
Handling discipline
A Result is an ordinary value. It can be stored in a struct, put in a list,
passed to a function, returned, or deliberately dropped. The language does not
turn it into an exception or a hidden control channel, so an accidentally
discarded result is a review question rather than a compiler one.
Keep each operation at the layer that can make the decision:
mapfor a local success-only transformation;flat_mapto sequence fallible work without nesting results;catchwhere recovery or domain conversion belongs;?where this function intentionally exposes the same error contract;matchwhen both channels drive explicit local behaviour.