Skip to content

Closures

Closure syntax, parameter inference, block bodies, captures and mutable captures, escaping closures, closure types, and trailing-closure calls.

Updated View as Markdown

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:

  1. which outer names it captures, and which of those are mut;
  2. whether the consumer may retain it or invoke it concurrently;
  3. what the environment keeps alive — capture a small stable identifier instead of a large object graph when the callback can reload what it needs;
  4. 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.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close