ADR-277: A Lambda Is fn(name) { … } and Takes the Arguments It Names; a Parameter That Is Code Has the Type fn(A) -> R, and Its Shape Follows the Type and Whether the Body Keeps It

Status: Accepted Date: October 3, 2026 History: the records this one replaces are archived in docs/history/adr; read them only for how the decision was reached Answers: how a lambda is written and how many arguments it takes (the silent fn: misparse; the review of the automatic a, b, c); how a package declares a parameter that is code, and what such a parameter may do (examples/http/ could not declare route); how such a parameter is lowered when the code may pause (#187), and the former open-decisions.md question does the body’s run-or-kept answer decide a function-typed parameter’s lowering?, which the owner answered with its option A Related: ADR-013 D5 (fn: as a chaining limitation, displaced by D1 here); ADR-289 D17 (a handler is a lambda); ADR-288 (a written sync is an assertion; NK2202); ADR-288 D15, D16 (sync = "from(f)" for a lambda run during the call, and its limit); ADR-024 (NK1117, which refuses an undeclared a); ADR-094 D1, D2 (the keeps column, and the callee’s body deciding the caller’s &; D17 applies D2 to a trait method); ADR-037 D7, ADR-283 D2 (one written type, a representation the analysis picks); ADR-140 D4 (sync and throws after the result, in a declaration as in a type); ADR-141 (a lambda carries no promise word); ADR-217 D2 (the kept lowering, nikaia_std::func::Kept; NK1142 retired); ADR-230 (what a function field’s stored code reaches, for the lock column); ADR-233 (a pausing lambda handed to std’s pausing counterpart); ADR-288 D31 (sync(f), the written form of pauses only where its lambda does); ADR-296 D12 (measure before choosing a shape); ADR-055 (a pausing function is an async fn)

1. Question

How is code passed as a value: how is a lambda written and how many arguments does it take; how does a function declared in this language say that a parameter is code and what that code may do; and what does such a parameter become in the language below, given that the code handed to it may pause?

Without an answer, a lambda’s body could swallow the chain after it, a local named a could silently change a lambda’s arity, a package could not take a handler, comparator or hook, and a pausing lambda had no lowering at a parameter that only runs it.

2. How others do it

Language / tool Its answer What it costs there Source
Kotlin, Ruby an expression-bodied block whose body ends at the end of the line the language is newline-sensitive at that point from memory
Rust (stable, 1.94.1, edition 2021) async \|x\| { … } closures and AsyncFn/AsyncFnMut/AsyncFnOnce bounds, no feature gate AsyncFn is a bound, not a type: a value stored in a field needs a box or a type parameter measured (D13)
Rust std Iterator::map takes FnMut; an async closure handed to it yields an iterator of futures a different program, not a pausing map D16
rayon rayon::join takes synchronous closures nothing can await inside one D16
Nikaia’s own precedent one written type, a representation chosen per value or per site: Shared[T] is Rc or Arc per value, a struct has up to three layouts, the callee’s body decides the caller’s & a reader consults the ledger for the representation ADR-037 D7, ADR-283 D2, ADR-094 D1

What does not transfer: Rust’s closure types. A Nikaia function type is written once and says what the code may do (sync, throws); which Rust shape carries it is the compiler’s choice.

3. Options, weighed

Question Option Why it lost, or won
Short lambda keep fn: expr silent misparse of a following .method(); two spellings of one thing; a body that cannot grow (D1, D5)
  fn: body ends at end of line newline sensitivity in one place, for four characters (D1)
  restrict the body so no chain follows no restriction keeps a.message.cmp(b.message) and refuses .listen(…)
  keep fn:, forbid a chain after it keeps every other cost and adds a rule
  lambda only inside the parentheses unambiguous, still allowed, but a chain of trailing lambdas reads better (Part II 12.6, Part III 17.1)
Arguments automatic a, b, c, arity read off the body a local named a becomes an argument; every lambda depends on the rule (D2)
  keep them, warned (NK1114) visible is not good; the rule stays alive with the warning (D2)
  announce the withdrawal a release ahead protects no code outside the repository (D2)
Run or kept a written @detached a word the body already answers; the ledger records it (D8)
Pausing parameter’s shape impl Fn(A) -> R for a run parameter a pausing lambda cannot be written into one (D10, D11)
  boxed future for run and kept alike 11.99 ns/call against 1.37 for impl AsyncFn, on a 0.31 floor (D13); kept only where the code is stored (D11)
  impl AsyncFn by the body’s keeps answer (D11) chosen: the run case pays 4.4× the floor instead of 38×; the cost is predictability, answered by the ledger (D15)

4. Decision

D1 — There is one lambda form, a block. fn: expression is removed.

A lambda is fn(name, …) { … }; the block is the body whether it holds one line or several. A block ends at its }, so nothing after it can be swallowed into it.

D2 — A lambda’s arguments are the ones it names.

fn(user) { … } takes one, fn(acc, n) { … } two. A lambda that names none takes none: fn { … } is the zero-argument form, which is what .or_insert_with fn { Stats(0) } takes. There are no automatic names: a body that uses a, b or c without declaring it names something nothing declares and meets NK1117, with no refusal of its own. NK1114 is retired and not reused.

D3 — A lambda that is the last argument may stand outside the parentheses.

Where there are no other arguments the parentheses go with it: users.map fn(user) { user.id }. This is where the argument goes, not a second spelling, and it is what lets a chain read down the page.

D4 — A method may take arguments and a trailing lambda.

.route("/x") fn { handler(db) } is one call, and the chain after the } is the outer chain:

Server()
    .route("/x") fn { handler(db) }
    .listen(":8080")

D5 — Writing fn: is an error that says what happened.

The message names the block form and the reason the short one went: its body ran to the end of the expression, so a .method() after it landed inside the lambda.

D6 — fn(A, B) -> R is a type, written the way a signature is.

Parameters in parentheses, -> R where there is a result, sync and throws after the result:

fn route(&mut self, path: &str, handler: fn(Request) -> Response)   // may pause
fn on_tick(&mut self, handler: fn() sync)                            // never pauses
fn load(&mut self, reader: fn(Path) -> Bytes throws)                 // may fail

A function type stands wherever a type does: a parameter, a field, a result, a let, an element. The trailing words are greedy: in fn make() -> fn(i64) -> i64 sync the sync belongs to the result type. A lambda itself carries no promise word; the promise is on the type it is handed to.

D7 — The type says what the code may do, with the language’s defaults.

Without sync the code may pause; with it, it never does. Without throws it cannot fail; with it, it may. A lambda that does less fits a type that allows more. The other direction is refused in the shape of the refusal one level down: a pausing lambda handed to a fn() sync is NK2206 (NK2202’s shape), a failing one handed to a type without throws is NK2606 (NK2605’s shape), reported alone, since the function around the lambda is not the one that has to answer for it.

D8 — Whether a code parameter is run or kept is inferred, and run is the absence of keeps.

Nothing is written for it; there is no @detached. The ledger’s existing keeps column records it: fn runs(f: fn() -> String) { return f() } carries no keeps, fn holds(f: fn() -> String) { spawn fn { f() } } carries keeps = ["f"]. A body that stores and runs gets the kept answer.

D9 — What a kept handler touches is everything, and what it may lock is anything.

The type carries sync and throws and no more, so a stored handler’s touches is everything and its locks may — the fail-closed answers for an undescribed call. A run lambda is unchanged: its body is in front of the check. Where a struct literal fills a function field, the lock column reads what is stored there instead (ADR-230 D1).

D10 — A lambda that pauses fits a parameter whose type allows pausing, run or kept.

There is no refusal of a pausing lambda at a run parameter declared in this language.

D11 — The shape follows the type, and for a pausing type the body’s keeps answer.

parameter’s type run kept
says sync plain closure, impl Fn(A) -> R Kept of a plain closure (ADR-217 D2)
may pause impl AsyncFn(A) -> R; the lambda is async \|a\| { … }, with no move Kept of a closure returning a boxed future (ADR-217 D2)

AsyncFn is a bound, and a value a field holds needs a type, which is why the kept case keeps the box. Where the type says throws, the closure’s result is the Result a throws function’s is. A call to a pausing parameter in the callee’s body carries an .await.

A run sync parameter of a function that lies on a cycle of the call graph is written &dyn Fn(A) -> R: impl Fn would make each new lambda a new instantiation, without end (#1023).

D12 — The shape is written only where the signature was declared in this language.

std’s lambda-taking entries are Rust with hand-written signatures, which take a synchronous closure whatever the ledger’s sync column says, so D11 does not apply to them and map and its kin pay nothing new. What a pausing lambda handed to one of them becomes is ADR-233’s.

D13 — The shape is measured, not assumed.

benches/handler keeps the rows, best of five, control tying: plain closure 0.31 ns/call, boxed future 11.99 (38×), impl AsyncFn 1.37 (4.4×). A premise that rules a shape out is checked too: stable Rust has async closures.

D14 — The declaration and the call read the keeps column the same way, including when it is absent.

A callee this build has no contract for reads as run at both ends. The declaration writes the parameter and the call writes the argument; the two disagreeing would be one parameter with two shapes, refused by rustc about a file nobody wrote — the rule ADR-094 D1 states for the &.

D15 — What D11 trades away is predictability, and the ledger answers for it.

A run parameter’s cost is not readable from its signature alone, and a library author who moves a body from run to kept moves every caller’s cost. A keeps change is a nikaia.contracts diff in review, and --locked fails a build whose contracts moved unrecorded — the same instrument ADR-094 and ADR-283 D4 use. sync on the type remains the way to ask for no future at all.

D16 — A task’s body and an overlap branch are blocks, not callables.

Changed by ADR-async-5 D10: a task an async form starts is such a block; overlap is removed.

spawn fn { … } and overlap { … } lower to async blocks: a body is called once, and wrapping it in a closure adds a call and takes nothing away. task::interleave and the overlap/race families take futures because a branch is that same block, and because rayon::join, the pool vehicle, takes synchronous closures.

D17 — A trait method keeps what its default body and its own package’s impls keep, and runs with neither.

Decided by the owner, 2026-10-30 (#1012, option E).

A trait method has no body of its own to read (ADR-094 D2), so its keeps column is the union, position by position, of its default body’s and those of every impl of the trait in the trait’s own package, private impls included. The column is computed inside the keeps fixpoint: the trait method is a node with an edge to each local impl and one back, so the trait and its impls converge to one column, and every impl, call site and lowering follows it (D11).

Rejected:

5. Consequences

6. What this does not decide

This site is open source. Improve this page.