Skip to content

Blocks

Statement order, tail expressions, lexical scope, shadowing, divergence, and block-scoped cleanup.

Updated View as Markdown

A block is a brace-delimited sequence of statements with an optional final expression. Every block is an expression: the final expression is its value.

fn total(): int {
    value := {
        first := 10
        second := 32
        first + second
    }
    return value
}

Statements run in source order. first and second exist only inside the braces; value is 42.

A block establishes three boundaries at once — a name scope, a cleanup scope for the values declared in it, and a type boundary whose tail expression supplies the value.

Tail values

A tail expression is the last expression in the block with nothing after it. It becomes the block’s value, which at a function boundary is the return value.

fn square(value: int): int {
    value * value
}

return is equivalent at the function boundary, but it exits immediately from wherever it appears rather than merely supplying the enclosing block’s value.

fn absolute(value: int): int {
    if value < 0 {
        return -value
    }
    value
}

A block can be annotated like any other expression, which is useful when the tail is a numeric literal that would otherwise default:

fn scaled(): f64 {
    factor: f64 = {
        base := 2.0
        base * 1.5
    }
    return factor
}

A block whose last statement is not an expression — or that is empty — has type void.

fn launch(): void { println("launched") }

fn maybe_launch(ready: bool): void {
    if ready {
        launch()
    }
}

Because the tail position is typed, a trailing call in a void function is a type error rather than a discarded statement:

fn side(): int { return 1 }

fn run(): void {
    side()
}

Anywhere but the tail, an expression statement runs for its effects and drops the value. Bind to _ when the discard should be visible at the tail:

fn side(): int { return 1 }

fn run(): void {
    side()
    _ := side()
}

Discarding a value never skips work: the call still runs, its errors still need handling, and its effects still propagate to the enclosing signature.

Scope

A local name is visible from its declaration to the closing brace of the enclosing block, and no further.

fn f(): int {
    {
        inner := 1
    }
    return inner
}

A nested block reads outer bindings freely and may shadow their names.

fn f(): string {
    name := "outer"
    {
        name := "inner"
        println(name)
    }
    return name
}

That prints inner and returns outer. Shadowing introduces a new binding; it never assigns to the shadowed one.

The initializer of a shadowing binding is resolved before the new name takes effect, so it can deliberately derive from the previous value:

fn f(raw: string): int {
    name := raw
    name := name.trim()
    return name.len()
}

These are two immutable bindings with different lexical identities, not two writes to one slot. Use mut when you want a single mutable slot instead:

fn f(raw: string): int {
    mut name := raw
    name = name.trim()
    return name.len()
}

Constants are declared at unit scope, not inside a block. A block-local const does not parse; hoist it out of the function.

const MAX_TRIES = 3

fn attempts(): int {
    mut count := 0
    for count < MAX_TRIES {
        count += 1
    }
    return count
}

Nested blocks for lifetime

A standalone nested block limits the lifetime of a name or a resource while still producing a value through its tail.

error IoError { Denied }

struct Handle { id: int }

impl Handle {
    fn open(id: int): Handle ! IoError {
        if id < 0 { error Denied }
        return Handle { id: id }
    }
    fn size(self): int { return self.id * 4 }
    fn close(self): void { println("closed ${self.id}") }
}

fn measure(id: int): int ! IoError {
    total := {
        handle := Handle.open(id)?
        defer handle.close()
        handle.size()
    }
    return total + 1
}

handle is closed at the inner closing brace — before total + 1 runs. The value produced by the tail escapes through total; everything else in the block is released.

Divergence

return, error, break, and continue have no normal continuation, so a branch that uses one contributes nothing to the block’s value type. The other branch alone decides it.

fn load(): int { return 7 }
fn fallback(): int { return 0 }

fn f(available: bool): int {
    value := if available {
        load()
    } else {
        return fallback()
    }
    return value + 1
}

The else branch does not have to manufacture an int, because execution never reaches the binding along that path.

Cleanup on every edge

Scope cleanup applies to every way control leaves a block, not just to falling off the end.

Exit Block cleanup
tail or fall-through runs before control continues outside
return or error runs while unwinding toward the function boundary
break or continue runs for each scope left on the way to the loop
propagated ? runs before the unsuccessful value leaves the function

The compiler inserts releases and user Drop dispatch at those points on its own. Use defer only when the paired action is a semantic operation you want visible in the source.

fn note(message: string): void { println(message) }

fn drain(values: []int): int {
    mut kept := 0
    for value in values {
        defer note("iteration finished")
        if value < 0 {
            continue
        }
        if value > 100 {
            break
        }
        kept += 1
    }
    return kept
}

Each iteration’s defer runs at the end of that iteration — including on the continue and on the break that leaves the loop entirely.

Special blocks

Some keywords introduce a block with extra checking or lowering rules. They are not interchangeable with an ordinary lexical block.

fn work(): int { return 7 }

fn run(): int {
    handle := spawn { work() }
    return handle.await()
}
fn f(): int {
    mut n := 0
    unsafe {
        n = 1
    }
    return n
}

spawn { ... } produces a Task[T], unsafe { ... } permits unsafe operations, and transaction { ... } wraps integrated SQL work atomically. Match arms, conditional branches, closure bodies, and loop bodies all contain ordinary blocks, so their locals follow exactly the rules above even though the surrounding construct adds selection, capture, or iteration semantics.

Putting it together

error ConfigError { Missing, Malformed }

struct Config { retries: int, verbose: bool }

fn read_field(source: []string, index: u32): string ! ConfigError {
    value := source.get(index) ?? ""
    if value.is_empty() { error Missing }
    return value
}

fn parse_config(source: []string): Config ! ConfigError {
    retries := {
        raw := read_field(source, 0)?
        if raw == "none" {
            0
        } else {
            raw.len()
        }
    }

    verbose := {
        raw := read_field(source, 1)?
        raw == "verbose"
    }

    if retries > 100 { error Malformed }
    return Config { retries: retries, verbose: verbose }
}

Both raw bindings are local to their own block, so the same name can describe two unrelated fields without interference, and each block’s tail is the only thing that escapes it.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close