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.