Skip to content

Propagation

Use postfix ? to carry None or Err through compatible function boundaries, and know exactly where it is legal.

Updated View as Markdown

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:

  1. evaluate the operand once;
  2. inspect its Ok/Err or Some/None case;
  3. on success, bind the payload and continue;
  4. on failure, convert into the enclosing carrier;
  5. run defers and managed cleanup for the scopes being left;
  6. 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.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close