Postfix ? produces the successful payload, or leaves the enclosing function
carrying the unsuccessful case. It works on both Result and Option, and the
enclosing function’s return type decides which is legal.
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 team_of(id: int): int ! LoadError {
user := load_user(id)?
return user.team
}Result propagation
For Result[T, E], ? yields T from Ok(T) and returns Err(E). The hand
written equivalent is a match:
error LoadError { NotFound }
struct User { name: string }
fn load_user(id: int): User ! LoadError {
if id < 1 { error NotFound }
return User { name: "ada" }
}
fn with_question(id: int): string ! LoadError {
user := load_user(id)?
return user.name
}
fn by_hand(id: int): string ! LoadError {
user := match load_user(id) {
Ok(value) => value
Err(e) => return Err(e)
}
return user.name
}The real lowering also preserves coercions, defers, and effect tracking around
that control edge. The operand is evaluated exactly once, and nothing after the
? in the surrounding expression runs on the failing path — in
src()? + other()?, other() never runs when the first call fails.
The complete failure path is:
- evaluate the operand once;
- inspect its
Ok/ErrorSome/Nonecase; - on success, bind the payload and continue;
- on failure, convert into the enclosing carrier;
- run defers and managed cleanup for the scopes being left;
- return to the caller.
Option propagation
For Option[T], ? yields T from Some(T) and returns None. The
enclosing function’s success type must itself be optional.
struct User { name: string, manager: int? }
fn manager_name(users: Map[int, User], id: int): string? {
user := users.get(id)?
boss := users.get(user.manager?)?
return Some(boss.name)
}An ordinary non-optional function cannot propagate None:
fn first(xs: []int): int {
v := xs.get(0)?
return v
}The diagnostic is ATOLL2002: ? on an option requires an enclosing function that returns an option.
Mixed carriers
Option ? does not invent an error for a fallible function, and result ?
does not invent a None for an optional one. Each of these is rejected:
error LoadError { NotFound }
fn cache_get(id: int): int? { return None }
fn load(id: int): int ! LoadError {
return cache_get(id)?
}error LoadError { NotFound }
fn load(id: int): int ! LoadError { error NotFound }
fn maybe_load(id: int): int? {
return Some(load(id)?)
}Convert absence into a failure explicitly with to_result, which is where you
also name what the absence means:
error LoadError { NotFound { id: int } }
struct User { name: string }
fn cache_get(id: int): User? { return None }
fn load(id: int): User ! LoadError {
missing := LoadError.NotFound(id)
return cache_get(id).to_result(missing)?
}A function can be both fallible and optional-valued. Inside it, option ?
propagates through the success side and result ? through the error side:
error NetworkError { Down }
struct User { name: string }
fn find_remote(id: int): User? ! NetworkError {
if id < 0 { error Down }
if id == 0 { return None }
return Some(User { name: "ada" })
}
fn label(id: int): string ! NetworkError {
maybe := find_remote(id)?
Some(user) := maybe else {
return "absent"
}
return user.name
}find_remote(id)? propagates NetworkError; the remaining User? is then
handled locally.
Error compatibility
The propagated error type must fit the enclosing function’s error type. Member into union is the common compatible case:
error AuthError { InvalidToken }
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)?
}When a function has no return annotation, several propagated error types synthesize an inferred union. When an annotation is present, its error side is the boundary and propagation does not widen it.
For an unrelated error type, convert first. catch produces the target error
and ? propagates it in the same expression:
error AuthError { InvalidToken, Expired }
error RequestError { Unauthorized }
fn authenticate(token: string): int ! AuthError {
if token.is_empty() { error InvalidToken }
return 7
}
fn current_user(token: string): int ! RequestError {
return authenticate(token) catch {
InvalidToken => RequestError.Unauthorized
Expired => RequestError.Unauthorized
}?
}Conversion should add context this layer owns. Renaming every lower-level variant one-for-one buys nothing; keep operator-relevant detail in a payload while sparing application callers a dependency on backend messages.
Parsing: ? versus ?.
?. is a single token, so expr?.field is optional member access — never
propagation followed by a field read. On a Result receiver that is an error:
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
}Any of these separate them:
error LoadError { NotFound }
struct User { name: string }
fn load_user(id: int): User ! LoadError { error NotFound }
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
}The bound form is the one to reach for by default: it names the intermediate value and leaves no doubt about which operator applies.
Loops and accumulation
? inside a loop exits the whole function, not just the iteration. That makes
“stop at the first failure” the default, which is usually what a pipeline
wants:
error ImportError { BadRow { line: int } }
fn parse_row(line: int, text: string): int ! ImportError {
v := text.to_int()
Some(n) := v else {
error BadRow { line: line }
}
return n
}
fn parse_all(rows: []string): []int ! ImportError {
mut out: []int
mut line := 0
for text in rows {
out.add(parse_row(line, text)?)
line += 1
}
return out
}To collect every failure instead of stopping, keep the results and inspect them afterwards:
error ImportError { BadRow { line: int } }
fn parse_row(line: int, text: string): int ! ImportError {
v := text.to_int()
Some(n) := v else {
error BadRow { line: line }
}
return n
}
fn partition(rows: []string): (int, int) {
mut ok := 0
mut bad := 0
mut line := 0
for text in rows {
if parse_row(line, text).is_ok() {
ok += 1
} else {
bad += 1
}
line += 1
}
return (ok, bad)
}Where ? is rejected
? is an error on a value that is neither Option nor Result:
fn f(): int {
v := 5?
return v
}That reports ATOLL3050: ? requires a result or option value, found int.
It cannot appear inside a defer body, because propagation cannot restart an
exit that is already in progress:
error IoError { Closed }
fn close(): int ! IoError { error Closed }
fn f(): int ! IoError {
defer { close()? }
return 1
}That reports ATOLL1019: ? cannot propagate out of a defer — handle errors inline with match or catch. Deferred cleanup must handle its own failures.
? also cannot appear inside a closure that is not itself fallible, so a
lambda passed to map or find has to deal with its own errors:
error E { Bad }
fn src(v: int): int ! E {
if v < 0 { error Bad }
return v
}
fn f(xs: []int): []int ! E {
return xs.map(v => src(v)?)
}Write the loop out when each element can fail — see the parse_all example
above.
Effects and cleanup
Result propagation records the error effect and a call edge for diagnostics.
Option propagation is ordinary absence control flow and does not by itself add
the error effect.
Both create early exits, and both run registered defers in reverse registration order while control leaves their scopes. A defer that has not been reached yet is not registered, and therefore does not run.
error IoError { Closed }
fn write(open: bool): int ! IoError {
mut acquired := false
defer {
if acquired { println("released") }
}
acquired = true
if not open {
error Closed
}
return 1
}Readability
Use ? when the current function intentionally exposes the failure without
acting on it. Use match or catch when this layer should recover, retry, add
context, translate the domain, or choose a fallback.
A long chain reads well when every step shares one boundary:
error StartupError { BadConfig, PortInUse }
struct Config { port: int }
fn load_config(path: string): Config ! StartupError {
if path.is_empty() { error BadConfig }
return Config { port: 8080 }
}
fn validate(c: Config): Config ! StartupError {
if c.port < 1024 { error BadConfig }
return c
}
fn start(c: Config): int ! StartupError {
if c.port == 80 { error PortInUse }
return c.port
}
fn boot(path: string): int ! StartupError {
config := load_config(path)?
validated := validate(config)?
return start(validated)?
}Break the chain wherever the policy changes. Retries, logging, metrics,
fallback data, and domain translation each deserve a named statement or a
catch block at the layer that owns the decision.