A closure is a lambda that reads variables from the function scope in which it is written. No explicit capture list is needed — the compiler detects which outer variables the lambda body uses and packages them automatically. How a variable travels into the closure depends on what it is: a scalar or a text is COPIED, and a collection or struct is SHARED. Both rules are below, and the difference between them is the thing to remember on this page.
Integer capture
A lambda may read any integer variable in scope. Store the lambda, then call it by name.
Multiple integer captures
A lambda can read more than one outer variable at once. All are captured when the lambda is written.
Text capture
Text values are captured by deep-copy, so they are independent of the original variable after capture.
Capture timing
Scalars and text are copied when the lambda is written (definition time), not when it is called. Changing such a variable afterwards does not change what the lambda sees.
Collections and structs are shared
Every collection — vector, hash, sorted, index, spatial — and every struct is captured by reference: the closure and the enclosing scope work on the same value, so changes travel BOTH ways.
Writing to a captured variable
A closure may assign to a captured scalar, and those writes are visible in the enclosing scope.
Cross-scope closures
A function can return a capturing lambda to the caller. The captured values travel with the lambda — no dangling references.
Closures with higher-order functions
A capturing closure can be called directly, and it can also be handed to map or filter — the captures travel with it.
Non-capturing lambdas with higher-order functions
Lambdas that use only their own parameters (no capture) also work fine.
Capturing a & parameter
A closure inside a function can use the function's & parameters. What it captures is what the parameter points at, by the same two rules as above.
What a closure cannot do
Two shapes the compiler refuses, and what replaces each.
struct Counter {
hits: integer,
}
struct Stepper {
advance: fn(integer) -> integer,
}
Zero every even element, from inside a closure, then append a marker.
fn clear_evens(v: &vector<integer>) {
wipe = fn(i: integer) { if v[i] % 2 == 0 { v[i] = 0; } };
for i in 0..len(v) { wipe(i); }
v += [9];
}
fn make_adder(base_val: integer) -> fn(integer) -> integer {
fn(n: integer) -> integer { base_val + n }
}
fn main() {
Integer capture
offset = 100;
shift = fn(x: integer) -> integer { x + offset };
assert(shift(5) == 105, "shift(5): {shift(5)}");
assert(shift(-3) == 97, "shift(-3): {shift(-3)}");
Multiple integer captures
lo = 10;
hi = 20;
clamp_val = fn(x: integer) -> integer {
if x < lo { lo } else if x > hi { hi } else { x }
};
assert(clamp_val(5) == 10, "clamp below: {clamp_val(5)}");
assert(clamp_val(15) == 15, "clamp mid: {clamp_val(15)}");
assert(clamp_val(25) == 20, "clamp above: {clamp_val(25)}");
Text capture
The closure holds its own copy of the text, so reassigning greeting afterwards leaves the message it builds unchanged.
greeting = "Hello";
make_msg = fn(name: text) -> text { "{greeting}, {name}!" };
greeting = "Goodbye";
assert(make_msg("World") == "Hello, World!", "greeting capture");
assert(make_msg("Loft") == "Hello, Loft!", "greeting capture 2");
Capture timing
base is 10 when the lambda is written; reassigning it to 20 afterwards does not affect the captured value.
base = 10;
add_base = fn(n: integer) -> integer { base + n };
base = 20;
assert(add_base(5) == 15, "sees base=10 at definition time: {add_base(5)}");
Collections and structs are shared
A collection is NOT copied the way the scalar above is: the closure works on the caller's vector. Appending to items after the lambda is written changes what the lambda counts, and a write made inside the lambda is visible outside it. That sharing is what lets a closure gather results into a vector the enclosing function keeps.
items = [1, 2, 3];
total = fn() -> integer {
sum = 0;
for e in items { sum += e; }
sum
};
assert(total() == 6, "before the append: {total()}");
items += [10];
assert(total() == 16, "the append reaches the closure: {total()}");
A struct behaves the same way, in the other direction: the closure writes through to the record the enclosing scope holds.
tally = Counter { hits: 0 };
record_hit = fn() { tally.hits += 5; };
record_hit();
assert(tally.hits == 5, "the closure's write is visible outside: {tally.hits}");
Writing to a captured variable
Assigning to a captured scalar shares it instead of copying it, so the closure's writes are visible for the rest of the function. This is how an accumulator is written. One restriction: a scalar a closure WRITES to may be captured by only one closure — two of them is refused, and the cure is to hold the shared state in a struct and capture that, as tally does above.
count = 0;
bump = fn() { count += 1; };
bump();
bump();
assert(count == 2, "the closure's writes reach the enclosing scope: {count}");
Cross-scope closures
make_adder returns a lambda that captured base_val from its parameter.
add10 = make_adder(10);
add100 = make_adder(100);
assert(add10(5) == 15, "add10(5): {add10(5)}");
assert(add100(5) == 105, "add100(5): {add100(5)}");
Closures with higher-order functions
factor = 3;
scale = fn(x: integer) -> integer { x * factor };
assert(scale(5) == 15, "scale(5): {scale(5)}");
The same closure passed to map — factor travels with it.
scaled = map([1, 2, 3], scale);
assert(scaled[0] == 3, "scaled[0]: {scaled[0]}");
assert(scaled[2] == 9, "scaled[2]: {scaled[2]}");
A capturing lambda written inline works too, in filter as well as map.
limit = 2;
big = filter([1, 2, 3, 4], |x| { x > limit });
assert(len(big) == 2, "len(big): {len(big)}");
assert(big[0] == 3, "big[0]: {big[0]}");
Non-capturing lambdas with higher-order functions
No capture needed here — the lambda uses only its own parameter.
nums = [1, 2, 3, 4, 5];
doubled = map(nums, |x| { x * 2 });
assert(doubled[0] == 2, "doubled[0]: {doubled[0]}");
assert(doubled[4] == 10, "doubled[4]: {doubled[4]}");
evens = filter(nums, |x| { x % 2 == 0 });
assert(evens[0] == 2, "evens[0]: {evens[0]}");
assert(evens[1] == 4, "evens[1]: {evens[1]}");
Capturing a & parameter
A &vector or a & struct is SHARED, so writes from inside the closure reach the caller's value. A &integer or a &text is COPIED when the closure is created, like any scalar: the closure can read it, and a write to it is refused (see below).
evens = [1, 2, 3, 4];
clear_evens(evens);
assert("{evens}" == "[1,0,3,0,9]", "writes through a captured '&vector' reach the caller: {evens}");
What a closure cannot do
A closure cannot WRITE a captured &integer or &text parameter: it holds a copy, so the write would not reach the caller, and the compiler says so. Copy the parameter into a local, let the closure update the local, and write it back before returning.
A capturing closure cannot be stored in a COLLECTION: vector<fn(…)> and the keyed collections take non-capturing lambdas only. A struct FIELD holds one without trouble, which is the shape to reach for.
step = 7;
stepper = Stepper { advance: fn(x: integer) -> integer { x + step } };
assert(stepper.advance(10) == 17, "a struct field holds a capturing closure: {stepper.advance(10)}");
}
Run it yourself
This page is a loft program. It runs here, in your browser — press Run, then ask it something.
Stop on a line
// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
// @NAME: Closures
// @TITLE: Capturing variables from the surrounding scope
// A closure is a lambda that reads variables from the function scope in which
// it is written. No explicit capture list is needed — the compiler detects
// which outer variables the lambda body uses and packages them automatically.
// How a variable travels into the closure depends on what it is: a scalar or a
// text is COPIED, and a collection or struct is SHARED. Both rules are below,
// and the difference between them is the thing to remember on this page.
// ## Integer capture
// A lambda may read any integer variable in scope.
// Store the lambda, then call it by name.
// ## Multiple integer captures
// A lambda can read more than one outer variable at once.
// All are captured when the lambda is written.
// ## Text capture
// Text values are captured by deep-copy, so they are independent of the
// original variable after capture.
// ## Capture timing
// Scalars and text are copied when the lambda is written (definition
// time), not when it is called. Changing such a variable afterwards does not
// change what the lambda sees.
// ## Collections and structs are shared
// Every collection — vector, hash, sorted, index, spatial — and every struct is
// captured by reference: the closure and the enclosing scope work on the same
// value, so changes travel BOTH ways.
// ## Writing to a captured variable
// A closure may assign to a captured scalar, and those writes are visible in
// the enclosing scope.
// ## Cross-scope closures
// A function can return a capturing lambda to the caller. The captured
// values travel with the lambda — no dangling references.
// ## Closures with higher-order functions
// A capturing closure can be called directly, and it can also be handed to
// map or filter — the captures travel with it.
// ## Non-capturing lambdas with higher-order functions
// Lambdas that use only their own parameters (no capture) also work fine.
// ## Capturing a '&' parameter
// A closure inside a function can use the function's '&' parameters. What it
// captures is what the parameter points at, by the same two rules as above.
// ## What a closure cannot do
// Two shapes the compiler refuses, and what replaces each.
struct Counter {
hits: integer,
}
struct Stepper {
advance: fn(integer) -> integer,
}
// Zero every even element, from inside a closure, then append a marker.
fn clear_evens(v: &vector<integer>) {
wipe = fn(i: integer) { if v[i] % 2 == 0 { v[i] = 0; } };
for i in 0..len(v) { wipe(i); }
v += [9];
}
fn make_adder(base_val: integer) -> fn(integer) -> integer {
fn(n: integer) -> integer { base_val + n }
}
fn main() {
// ## Integer capture
offset = 100;
shift = fn(x: integer) -> integer { x + offset };
assert(shift(5) == 105, "shift(5): {shift(5)}");
assert(shift(-3) == 97, "shift(-3): {shift(-3)}");
// ## Multiple integer captures
lo = 10;
hi = 20;
clamp_val = fn(x: integer) -> integer {
if x < lo { lo } else if x > hi { hi } else { x }
};
assert(clamp_val(5) == 10, "clamp below: {clamp_val(5)}");
assert(clamp_val(15) == 15, "clamp mid: {clamp_val(15)}");
assert(clamp_val(25) == 20, "clamp above: {clamp_val(25)}");
// ## Text capture
// The closure holds its own copy of the text, so reassigning 'greeting'
// afterwards leaves the message it builds unchanged.
greeting = "Hello";
make_msg = fn(name: text) -> text { "{greeting}, {name}!" };
greeting = "Goodbye";
assert(make_msg("World") == "Hello, World!", "greeting capture");
assert(make_msg("Loft") == "Hello, Loft!", "greeting capture 2");
// ## Capture timing
// 'base' is 10 when the lambda is written; reassigning it to 20 afterwards
// does not affect the captured value.
base = 10;
add_base = fn(n: integer) -> integer { base + n };
base = 20;
assert(add_base(5) == 15, "sees base=10 at definition time: {add_base(5)}");
// ## Collections and structs are shared
// A collection is NOT copied the way the scalar above is: the closure works
// on the caller's vector. Appending to 'items' after the lambda is written
// changes what the lambda counts, and a write made inside the lambda is
// visible outside it. That sharing is what lets a closure gather results
// into a vector the enclosing function keeps.
items = [1, 2, 3];
total = fn() -> integer {
sum = 0;
for e in items { sum += e; }
sum
};
assert(total() == 6, "before the append: {total()}");
items += [10];
assert(total() == 16, "the append reaches the closure: {total()}");
// A struct behaves the same way, in the other direction: the closure writes
// through to the record the enclosing scope holds.
tally = Counter { hits: 0 };
record_hit = fn() { tally.hits += 5; };
record_hit();
assert(tally.hits == 5, "the closure's write is visible outside: {tally.hits}");
// ## Writing to a captured variable
// Assigning to a captured scalar shares it instead of copying it, so the
// closure's writes are visible for the rest of the function. This is how an
// accumulator is written. One restriction: a scalar a closure WRITES to may
// be captured by only one closure — two of them is refused, and the cure is
// to hold the shared state in a struct and capture that, as 'tally' does
// above.
count = 0;
bump = fn() { count += 1; };
bump();
bump();
assert(count == 2, "the closure's writes reach the enclosing scope: {count}");
// ## Cross-scope closures
// make_adder returns a lambda that captured base_val from its parameter.
add10 = make_adder(10);
add100 = make_adder(100);
assert(add10(5) == 15, "add10(5): {add10(5)}");
assert(add100(5) == 105, "add100(5): {add100(5)}");
// ## Closures with higher-order functions
factor = 3;
scale = fn(x: integer) -> integer { x * factor };
assert(scale(5) == 15, "scale(5): {scale(5)}");
// The same closure passed to map — 'factor' travels with it.
scaled = map([1, 2, 3], scale);
assert(scaled[0] == 3, "scaled[0]: {scaled[0]}");
assert(scaled[2] == 9, "scaled[2]: {scaled[2]}");
// A capturing lambda written inline works too, in filter as well as map.
limit = 2;
big = filter([1, 2, 3, 4], |x| { x > limit });
assert(len(big) == 2, "len(big): {len(big)}");
assert(big[0] == 3, "big[0]: {big[0]}");
// ## Non-capturing lambdas with higher-order functions
// No capture needed here — the lambda uses only its own parameter.
nums = [1, 2, 3, 4, 5];
doubled = map(nums, |x| { x * 2 });
assert(doubled[0] == 2, "doubled[0]: {doubled[0]}");
assert(doubled[4] == 10, "doubled[4]: {doubled[4]}");
evens = filter(nums, |x| { x % 2 == 0 });
assert(evens[0] == 2, "evens[0]: {evens[0]}");
assert(evens[1] == 4, "evens[1]: {evens[1]}");
// ## Capturing a '&' parameter
// A '&vector' or a '&' struct is SHARED, so writes from inside the closure
// reach the caller's value. A '&integer' or a '&text' is COPIED when the
// closure is created, like any scalar: the closure can read it, and a write to
// it is refused (see below).
evens = [1, 2, 3, 4];
clear_evens(evens);
assert("{evens}" == "[1,0,3,0,9]", "writes through a captured '&vector' reach the caller: {evens}");
// ## What a closure cannot do
// A closure cannot WRITE a captured '&integer' or '&text' parameter: it holds
// a copy, so the write would not reach the caller, and the compiler says so.
// Copy the parameter into a local, let the closure update the local, and write
// it back before returning.
//
// A capturing closure cannot be stored in a COLLECTION: 'vector<fn(…)>' and
// the keyed collections take non-capturing lambdas only. A struct FIELD holds
// one without trouble, which is the shape to reach for.
step = 7;
stepper = Stepper { advance: fn(x: integer) -> integer { x + step } };
assert(stepper.advance(10) == 17, "a struct field holds a capturing closure: {stepper.advance(10)}");
}