Skip to content

Conditionals

Boolean branches, if as an expression, pattern conditions, refutable bindings, and early exits.

Updated View as Markdown

if selects a branch from a bool condition.

fn start(): void { println("start") }
fn wait(): void { println("wait") }

fn run(ready: bool): void {
    if ready {
        start()
    } else {
        wait()
    }
}

else is optional. An if without one runs for its effects.

fn f(debug_enabled: bool, state: int): void {
    if debug_enabled {
        println("state=${state}")
    }
}

The condition must be a bool

Atoll has no truthiness. Numbers, strings, collections, and optionals are not converted to booleans.

fn f(n: int): int {
    if n {
        return 1
    }
    return 0
}
fn f(values: []int): int {
    if values.len() {
        return 1
    }
    return 0
}
fn f(value: int?): int {
    if value {
        return 1
    }
    return 0
}

Write the comparison you mean:

fn describe(values: []int, value: int?): string {
    if values.is_empty() {
        return "empty"
    }
    if value.is_some() {
        return "has value"
    }
    if values.len() > 10 {
        return "large"
    }
    return "small"
}

if as an expression

The selected branch’s value becomes the value of the whole if. There is no ternary operator; a one-line if is the compact form.

fn grade(score: int): string {
    label := if score >= 90 {
        "excellent"
    } else if score >= 60 {
        "passing"
    } else {
        "retry"
    }
    return label
}

fn sign(value: int): int {
    return if value >= 0 { 1 } else { -1 }
}

When a value is required, every normally completing branch must produce a compatible type. With an expected type in place, each branch is checked against it individually, so a literal can widen or lift without an intermediate binding:

fn estimate(): f64 { return 0.5 }

fn measure(exact: bool): f64 {
    value: f64 = if exact {
        1.0
    } else {
        estimate()
    }
    return value
}

Branches that cannot agree are rejected at the branch, not at the binding:

fn f(use_number: bool): int {
    value: int = if use_number {
        1
    } else {
        "two"
    }
    return value
}

Without an expected type the checker joins the branches, which for unrelated types produces an anonymous union you then have to match:

fn render(use_number: bool): string {
    value := if use_number {
        42
    } else {
        "forty-two"
    }
    return match value {
        n: int => "number ${n}"
        s: string => s
    }
}

An if used as a statement can leave else off. An if used for its value generally needs one, because a missing else contributes void.

Chains

else if is an ordered sequence. Conditions are evaluated only until one matches, and the branches that are not selected have no runtime effect.

fn category(value: int): string {
    return if value < 0 {
        "negative"
    } else if value == 0 {
        "zero"
    } else if value < 100 {
        "small"
    } else {
        "large"
    }
}

Reach for match when the decision is about variants, destructuring, or exhaustively covering a closed type — a long else if chain over one enum loses the coverage check.

Pattern conditions

if pattern := expression runs the first branch only when the expression matches. The expression is evaluated once, and the pattern’s bindings exist only inside the successful branch.

struct User { id: int, name: string }

fn lookup(id: int): User? {
    if id < 0 { return None }
    return Some(User { id: id, name: "user-${id}" })
}

fn greet(id: int): string {
    if Some(user) := lookup(id) {
        return "hello ${user.name}"
    } else {
        return "unknown"
    }
}

The same form works for Result, for enum variants, and for any other pattern:

error ParseError { Invalid }

fn parse(text: string): int ! ParseError {
    if text.is_empty() { error Invalid }
    return text.len()
}

fn width(text: string): int {
    if Ok(value) := parse(text) {
        return value
    } else if text.is_empty() {
        return 0
    } else {
        return -1
    }
}

The failure branch cannot read names the pattern never initialized:

fn f(value: int?): int {
    if Some(v) := value {
        return v
    } else {
        return v
    }
}

Bind error payloads as err or e — error is a keyword and is never an identifier.

Refutable bindings

Pattern := expression else { ... } binds names for the rest of the enclosing block and puts the failure path in the else.

struct User { id: int, name: string }

fn lookup(id: int): User? {
    if id < 0 { return None }
    return Some(User { id: id, name: "user-${id}" })
}

fn name_of(id: int): string? {
    Some(user) := lookup(id) else {
        return None
    }
    return Some(user.name)
}

This is the difference from if pattern := ...: the successful names stay visible afterwards, so the happy path is not indented. The price is that the else block must diverge, because control cannot continue with uninitialized names.

fn f(value: int?): int {
    Some(v) := value else {
        println("missing")
    }
    return v
}

The compiler answers with let-else's else branch must diverge (return / break / continue / throw / loop). Valid divergence is return, error, and — inside a loop — break or continue:

error ConfigError { MissingPort }

fn configured_port(): int? { return None }

fn connect_all(hosts: []string): int ! ConfigError {
    mut connected := 0
    for host in hosts {
        Some(port) := configured_port() else {
            continue
        }
        if port <= 0 { error MissingPort }
        connected += 1
    }
    return connected
}

Early exits

A branch that always transfers control does not constrain the value type of the branch that completes normally.

error ParseError { Empty }

fn parse(text: string): int ! ParseError {
    if text.is_empty() { error Empty }
    return text.len()
}

fn width_or_default(text: string): int ! ParseError {
    result := if text.is_empty() {
        return 0
    } else {
        parse(text)?
    }
    return result
}

This is the general never-type rule, not a special case for if. The same holds for error, break, and continue.

Putting it together

error RouteError { NotFound, Rejected { reason: string } }

struct Request { method: string, path: string, token: string }

struct Response { status: int, body: string }

fn authorize(token: string): int? {
    if token.is_empty() { return None }
    return Some(token.len())
}

fn handle(request: Request): Response ! RouteError {
    Some(user) := authorize(request.token) else {
        error Rejected { reason: "missing token" }
    }

    if request.method != "GET" {
        error Rejected { reason: "method ${request.method}" }
    }

    body := if request.path == "/" {
        "index"
    } else if request.path == "/me" {
        "user ${user}"
    } else {
        error NotFound
    }

    status := if body.is_empty() { 204 } else { 200 }
    return Response { status: status, body: body }
}

The refutable binding keeps user in scope for the whole function, the else if chain picks a body, and the error NotFound branch diverges so it does not have to produce a string.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close