A closure is an unnamed function value written with =>. It inhabits the same
fn(A, B) -> R types as a named function, so anywhere a function value is
accepted, a closure will do.
fn main(): void {
double := (v: int) => v * 2
add := (a: int, b: int) => a + b
constant := () => 42
println("${double(21)} ${add(20, 22)} ${constant()}")
}Parameters
A single parameter may be written bare; zero or several parameters need parentheses.
fn main(): void {
square := (v: int) => v * v
nothing := () => 0
sum := (left: int, right: int) => left + right
println("${square(7)} ${nothing()} ${sum(1, 2)}")
}Parentheses around a multi-parameter list are required — a bare a, b => ...
does not parse as a closure:
fn main(): void {
add := a, b => a + b
println("${add(1, 2)}")
}Parameter types come from the expected function type when there is one, so a closure passed as an argument usually needs no annotations at all.
fn apply(value: int, operation: fn(int) -> int): int => operation(value)
fn main(): void {
println("${apply(21, v => v * 2)}")
println("${apply(21, (v) => v + 1)}")
println("${apply(21, (v: int) => v - 1)}")
}Annotate when nothing supplies the expectation, for example when the closure is bound to a local before it is ever passed anywhere:
fn main(): void {
compare := (left: int, right: int) => left < right
format := (label: string, value: int) => "${label}=${value}"
println("${compare(1, 2)} ${format("count", 3)}")
}Closure parameters do not take defaults, and there is no variadic form.
Bodies
The expression after => is the result. It can be any expression, including
an if or a match.
enum Level { Low, High }
fn main(): void {
classify := (v: int) => if v < 0 { "negative" } else { "non-negative" }
limit := (l: Level) => match l {
Low => 10
High => 90
}
println("${classify(-3)} ${limit(High)}")
}A brace block gives statements and an early return out of the closure
itself. The final expression is still the result.
fn main(): void {
normalize := (v: int) => {
if v < 0 {
return -v
}
v
}
describe := (xs: []int) => {
mut total := 0
for x in xs {
total += x
}
"${xs.len()} values, total ${total}"
}
println("${normalize(-4)} ${describe([1, 2, 3])}")
}return inside a closure leaves the closure, not the enclosing function.
For the same reason, error and ? belong to the enclosing function’s error
contract, not the closure’s: a closure cannot raise an error of its own. To
supply a fallible callback, call a fallible function from inside it.
error MathError { DivideByZero }
fn checked_div(a: int, b: int): int ! MathError {
if b == 0 {
error DivideByZero
}
return a / b
}
fn run(op: fn(int) -> Result[int, MathError], v: int): int => op(v).unwrap_or(-1)
fn main(): void {
safe := (v: int) => checked_div(100, v)
println("${run(safe, 5)} ${run(safe, 0)}")
}The closure’s result type is Result[int, MathError], which is exactly what a
call to checked_div produces, so it matches the parameter’s function type.
Captures
A closure may use names from the surrounding scope. The compiler works out the capture set; there is no capture list in source.
fn main(): void {
factor := 3
prefix := "item"
scale := (v: int) => v * factor
label := (v: int) => "${prefix}: ${v}"
println("${scale(5)} ${label(7)}")
}Only referenced outer locals are captured, and a name declared inside the closure body is local to that invocation rather than a capture. Captures are established when the closure value is created, so introducing another binding afterwards does not retarget an existing closure.
Capturing does not grant mutability. An immutable binding stays immutable inside the closure:
fn main(): void {
count := 0
bump := () => {
count += 1
count
}
println("${bump()}")
}Mutable captures
A mut binding can be read and updated through a closure. The closure and the
enclosing scope share one place, so the updates are visible to both.
fn make_counter(): fn() -> int {
mut count := 0
return () => {
count += 1
count
}
}
fn main(): void {
next := make_counter()
println("${next()} ${next()} ${next()}")
}Closures created in the same scope share the same captured place, so several callbacks can cooperate over one piece of state:
struct Tally {
add: fn(int) -> int
read: fn() -> int
}
fn make_tally(): Tally {
mut total := 0
return Tally {
add: v => {
total += v
total
},
read: () => total,
}
}
fn main(): void {
t := make_tally()
t.add(20)
t.add(22)
println("${t.read()}")
}Because that is shared state, invocation order is observable. A captured mutable local is not an implicit cross-task atomic — concurrent use needs an API whose ownership contract permits it.
Escaping closures
A closure escapes when it can outlive the activation that created it: when it is returned, stored in a longer-lived value, or handed to a callback whose lifetime is unknown. Returning one is ordinary:
fn make_adder(amount: int): fn(int) -> int => v => v + amount
fn compose(f: fn(int) -> int, g: fn(int) -> int): fn(int) -> int => v => g(f(v))
fn main(): void {
add5 := make_adder(5)
double := (v: int) => v * 2
pipeline := compose(add5, double)
println("${add5(1)} ${pipeline(1)}")
}make_adder returns a closure that still needs amount, so the compiler
allocates a typed environment for it and keeps the captured values alive as
long as the closure is reachable. A closure that never escapes may instead be
inlined or lowered to a direct call. Neither choice is visible in source: there
is no closure-kind annotation, and no manual environment allocation or release.
Closure types
A closure’s type is a fn(...) -> ... type, so named functions and closures
are interchangeable wherever one is expected.
fn twice(value: int): int => value * 2
fn apply_all(value: int, operations: []fn(int) -> int): []int {
mut out: []int = []
for op in operations {
out.add(op(value))
}
return out
}
fn main(): void {
named: fn(int) -> int = twice
anonymous: fn(int) -> int = v => v * 2
results := apply_all(3, [named, anonymous, v => v + 100])
println("${results.len()} ${results[2] ?? 0}")
}Compatibility covers the parameter types and the result type, including a
Result result — a closure is never erased into an untyped callback.
Closure identity is not structural. Two evaluations of the same closure expression may produce different environments, so do not use a closure as a registration key; store an explicit identifier alongside it when a callback must later be found and removed.
struct Subscription {
id: int
on_event: fn(string) -> void
}
fn notify(subs: []Subscription, message: string): void {
for s in subs {
s.on_event("[${s.id}] ${message}")
}
}
fn main(): void {
subs := [
Subscription { id: 1, on_event: m => println(m) },
Subscription { id: 2, on_event: m => println("audit ${m}") },
]
notify(subs, "deploy finished")
}Trailing-closure calls
When the last argument is a closure, the braces may follow the argument list instead of sitting inside it. This is how the collection higher-order methods are normally written.
struct Order {
id: int
cents: int
paid: bool
}
fn main(): void {
orders := [
Order { id: 1, cents: 2000, paid: true },
Order { id: 2, cents: 500, paid: false },
Order { id: 3, cents: 5000, paid: true },
]
paid := orders.filter { o => o.paid }
ids := orders.map { o => o.id }
revenue := orders.sum_of { o => o.cents }
largest := orders.max_by { o => o.cents }
any_free := orders.any { o => o.cents == 0 }
println("${paid.len()} ${ids.len()} ${revenue} ${largest?.id ?? 0} ${any_free}")
}Writing the closure inside the parentheses is the same call — the braces are purely a layout choice, and neither form is preferred by the language:
fn main(): void {
values := [1, -2, 3, -4]
braced := values.filter { v => v > 0 }
parenthesised := values.filter(v => v > 0)
println("${braced.len()} ${parenthesised.len()}")
}Reach for the braces when the callback body spans lines, and keep the parentheses when it is a short expression sitting among other arguments. The same form works for user functions:
fn each_line(lines: []string, body: fn(string) -> void): void {
for line in lines {
body(line)
}
}
fn main(): void {
mut shown := 0
each_line(["alpha", "", "gamma"]) { line =>
if line.len() == 0 {
return
}
shown += 1
println("${shown}: ${line}")
}
println("shown ${shown}")
}Note that the closure’s return skips the blank line and continues the loop —
it exits the callback, not each_line and not main.
Multi-parameter callbacks still need their parentheses inside the braces:
fn main(): void {
values := [1, 2, 3, 4]
total := values.reduce(0) { (acc, v) => acc + v }
println("${total}")
}A complete example
Closures as struct fields, as parameters, as return values, and over a captured local — a small rule engine:
struct Event {
name: string
severity: int
resolved: bool
}
struct Rule {
label: string
accept: fn(Event) -> bool
}
fn all_of(rules: []Rule): fn(Event) -> bool {
return e => {
for r in rules {
if !r.accept(e) {
return false
}
}
return true
}
}
fn count_matching(events: []Event, accept: fn(Event) -> bool): int {
mut n := 0
for e in events {
if accept(e) {
n += 1
}
}
return n
}
fn main(): void {
events := [
Event { name: "disk", severity: 3, resolved: false },
Event { name: "net", severity: 1, resolved: true },
Event { name: "cpu", severity: 5, resolved: false },
]
threshold := 2
rules := [
Rule { label: "open", accept: e => !e.resolved },
Rule { label: "severe", accept: e => e.severity >= threshold },
]
accept := all_of(rules)
println("matching: ${count_matching(events, accept)}")
names := events.filter { e => accept(e) }.map { e => e.name }
println("first: ${names[0] ?? "none"}")
mut scanned := 0
events.for_each_item { e =>
scanned += 1
}
println("scanned: ${scanned}")
}all_of returns an escaping closure that captures the rules list; each
Rule.accept captures either nothing or the local threshold; and the
for_each_item callback mutates the captured scanned.
Review
Before a closure crosses the statement that created it, check:
- which outer names it captures, and which of those are
mut; - whether the consumer may retain it or invoke it concurrently;
- what the environment keeps alive — capture a small stable identifier instead of a large object graph when the callback can reload what it needs;
- whether it can suspend while a captured reference is still live.
A closure passed to a suspend-capable function is treated as escaping unless analysis proves otherwise, which is why an immediately invoked closure can optimize differently from one stored in a task or a stream callback.