Skip to content

Expressions

Value-producing forms, postfix chains, places, expected types, and divergence.

Updated View as Markdown

An expression computes a value, identifies a place, or transfers control. Atoll is expression-oriented: blocks, if, and match are expressions, so a value can come straight out of a branch instead of through a mutable accumulator.

fn calculate(): int { return 42 }

fn f(ready: bool): int {
    result := if ready {
        calculate()
    } else {
        0
    }
    return result
}

The families

Family Examples
Atomic 42, name, None, Color.Red
Aggregate (a, b), [1, 2], { x: 1 }, Point { x: 1, y: 2 }
Postfix .field, .0, f(x), xs[i], ?., ?, catch
Prefix -value, !ready, ~mask, &place
Infix arithmetic, comparison, logic, ranges, ??
Control block, if, match, for
Function v => v + 1, trailing closures
Concurrency spawn, race, await
Transfer return, error, break, continue
Domain integrated SQL queries and transactions

Bindings, const, and defer are statements. They contain expressions but never become a block’s tail value.

Primary expressions

A primary expression is a complete expression that a postfix or infix operator can extend.

struct Point { x: int, y: int }

fn prepare(): void {}
fn finish(): int { return 1 }

fn f(condition: bool, value: int?): void {
    a := 42
    b := (1, 2)
    c := [1, 2, 3]
    d := Point { x: 1, y: 2 }
    e := { prepare(); finish() }
    g := if condition { 1 } else { 2 }
    h := match value { Some(v) => v None => 0 }
    println("${a} ${b.0} ${c.len()} ${d.x} ${e} ${g} ${h}")
}

Parentheses group and override precedence. A comma inside them makes a tuple instead. Braces mean one of three things depending on position: a block, a nominal value after a type or variant name, or an anonymous record when the contents have a field shape.

Postfix chains

Postfix operations extend a completed expression left to right; each receiver is the result of the previous step.

struct Profile { name: string }
struct Account { profile: Profile? }

fn f(account: Account?, values: []int): void {
    a := account?.profile?.name
    b := values.filter(v => v > 0).map(v => v * 2).len()
    c := (1, "two").1
    println("${a ?? "none"} ${b} ${c}")
}

A method call is member selection plus a call. A field access never runs a getter — point.x reads storage, full stop.

The fresh-line rule

A ( or [ starting a fresh line does not continue the previous expression. This is the one place where a newline is significant.

fn handler(x: int): int { return x }

fn f(): void {
    request := 1
    handler
    (request)
}

Dots and ? markers do continue across lines, so format long chains by leading each continuation with its marker:

struct Profile { name: string }
struct Client { }

fn Client.load(self, id: int): Profile? { return None }

fn f(client: Client): string? {
    return client
        .load(3)
        ?.name
}

Calls

A callee can be a named function, a method, a closure, or any function-valued expression. Arguments are positional and evaluate left to right.

fn double(x: int): int { return x * 2 }
fn choose_handler(): fn(int) -> int { return double }

fn f(text: string): int {
    a := double(21)
    b := text.trim().len()
    c := (choose_handler())(21)
    return a + b + c
}

Construction looks like a call but is a distinct operation: Point { ... }, a tuple variant, and a static function on a type stay separate in the AST and in type checking even though they all produce values.

struct Point { x: int, y: int }

enum Value { Num(int), Text(string) }

fn Point.origin(): Point => Point { x: 0, y: 0 }

fn f(): void {
    a := Point { x: 1, y: 2 }     // struct construction
    b := Point.origin()           // static function
    c := Value.Num(3)             // tuple variant
    println("${a.x} ${b.x} ${c == Value.Num(3)}")
}

Values and places

Most expressions produce a value. A subset also names a place — storage that can be read, assigned, or referenced.

struct Point { x: int, y: int }

fn f(): int {
    mut point := Point { x: 1, y: 2 }
    mut grid := [10, 20, 30]

    read := point.x        // read a place
    point.x = 3            // assign a place
    grid[0] = 99           // assign an indexed place
    r := &point.y          // reference a place

    return read + point.x + (grid[0] ?? 0) + r
}

Assignment needs the whole access path to be mutable. A temporary is a value, not a durable place, so it cannot be assigned or outlive the expression that built it. Reference lifetimes are covered in References.

& also appears in signatures, where it lets a function borrow instead of taking ownership:

struct Point { x: int, y: int }

fn manhattan(p: &Point): int {
    return p.x + p.y
}

fn count(values: &[]int): int {
    return values.len()
}

fn f(): int {
    p := Point { x: 3, y: 4 }
    xs := [1, 2, 3]
    return manhattan(&p) + count(&xs)
}

Indexing returns an Option

Indexing a list or an array yields T?, not T. There is no out-of-range trap to think about, but there is always an Option to discharge.

fn f(values: []int): int {
    a := values[0] ?? 0
    b := values.get(1).unwrap_or(0)
    c := match values[2] {
        Some(v) => v
        None => -1
    }
    return a + b + c
}

Forgetting the discharge is a type error, not a silent unwrap:

fn f(values: []int): int {
    return values[0]
}

Expected types

An expression is checked with information from where it sits.

Position Expected type
binding annotation the declared type
function argument the parameter type
return / function tail the function’s success type
struct or record field the declared field type
collection element the element type
branch or match arm the join of the other arms, or the surrounding expectation
statement position none — the value is discarded

The expectation is what lets an incomplete literal finish:

fn f(): void {
    ports: []u16 = [80, 443]                       // literal width from the annotation
    fallback: string? = None                       // Option payload from the annotation
    handler: fn(int) -> bool = value => value > 0   // closure parameter from the annotation
    println("${ports.len()} ${fallback ?? "none"} ${handler(1)}")
}

Expectations select literal widths, infer closure parameters, and lift a value into a carrier. They never enable an arbitrary conversion and never postpone a decision to runtime.

Branches as values

if and match produce values when every arm produces a compatible one.

fn classify(n: int): string {
    return match n {
        0 => "zero"
        1 => "one"
        _ => "many"
    }
}

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

A block’s tail is its value; a semicolon discards it and makes the block void.

fn compute(): int { return 7 }

fn f(): void {
    value := { compute() }      // int
    { compute(); }              // void
    println("${value}")
}

Statement position

An expression used as a statement is evaluated and its value dropped. Dropping the value drops nothing else:

fn send(message: string): int { return message.len() }

fn f(): void {
    mut counter := 0
    send("hello")       // the returned int is discarded; the call still happens
    counter += 1
    println("${counter}")
}

Mutation, host operations, task spawns, destructors, failures, and suspension all still occur. A fallible call in statement position still has to be handled.

Divergence

return, error, break, and continue are expressions with the never type: they produce no value because control leaves. That lets them sit anywhere an expression is expected, including on the right of ??.

fn fallback(): int { return -1 }

fn f(maybe_value: int?): int {
    value := maybe_value ?? return fallback()
    return value * 2
}

fn total(values: []int?): int {
    mut sum := 0
    for value in values {
        v := value ?? continue
        sum += v
    }
    return sum
}

The same rule is what makes a diverging catch arm yield the plain success type — see Catch.

Error and concurrency expressions

? propagates the error channel; catch converts or discharges it.

error LoadError { NotFound, Corrupt }

fn read(id: int): int ! LoadError {
    if id < 0 { error NotFound }
    return id * 2
}

fn propagate(id: int): int ! LoadError {
    value := read(id)?
    return value + 1
}

fn recover(id: int): int {
    value := read(id) catch {
        NotFound => return 0
        Corrupt => return -1
    }
    return value
}

spawn yields a Task[T], race picks the first arm to finish, and select is a statement that waits on several handles.

fn slow(): int { return 1 }
fn fast(): int { return 2 }

fn concurrent(): int {
    task := spawn { slow() }
    winner := race { slow() fast() }
    return task.await() + winner
}

Note the arms of race are separated by whitespace only, and select evaluates to void:

fn f(): void {
    task := spawn { 1 }
    select {
        value := task => println("got ${value}")
        default => println("nothing ready")
    }
}

What the spelling does not tell you

Parsing decides grouping; name resolution picks declarations; type checking resolves generics, overloads, and branch joins; effect checking records capabilities. No spelling alone proves purity:

  • a member access is a field read, but an operator or method may dispatch user code through a trait;
  • a query is an expression, but it crosses a host boundary;
  • a closure literal is inert until called, yet it retains whatever it captured.

Composed example

error ParseError {
    Empty
    BadNumber { field: string }
}

struct Record {
    id: int
    score: float
    tags: []string
}

fn parse_field(row: []string, index: int, name: string): string ! ParseError {
    value := row[index] ?? error BadNumber { field: name }
    if value.is_empty() { error Empty }
    return value
}

fn parse_row(row: []string): Record ! ParseError {
    id_text := parse_field(row, 0, "id")?
    score_text := parse_field(row, 1, "score")?

    id := id_text.to_int() ?? error BadNumber { field: "id" }
    score := score_text.to_float() ?? error BadNumber { field: "score" }
    tags := (row[2] ?? "").split(",").filter(t => t.is_not_empty())

    return Record { id, score, tags }
}

fn summarize(rows: [][]string): string {
    mut kept := 0
    mut skipped := 0
    mut best := 0.0

    for row in rows {
        record := parse_row(row) catch {
            Empty => { skipped += 1 continue }
            BadNumber { field: _ } => { skipped += 1 continue }
        }
        kept += 1
        if record.score > best {
            best = record.score
        }
    }

    status := if skipped == 0 { "clean" } else { "partial" }
    return "${status}: kept ${kept}, skipped ${skipped}, best ${best}"
}

fn main(): void {
    rows := [
        ["1", "9.5", "a,b"],
        ["2", "oops", ""],
        ["3", "7.25", "c"],
    ]
    println(summarize(rows))
}
Navigation

Type to search…

↑↓ navigate↵ selectEsc close