Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

ADR 0028: Block lambdas and trait methods as values

Status: accepted, 2026-10-10

Problem

The reference said lambda bodies are one expression and that multi-line logic belongs in a named fn (ADR 0002, decision 10). Agents write multi-line lambdas anyway, in every language they know, and each rejection costs a turn: move the body out, name it, pass the name. The lexer had in fact kept layout inside brackets after a line ending in do, =>, then, else or = since Phase 3, so xs.map(x => do and an indented block already parsed, but nothing documented it, a ) on its own line at the block’s column failed, braces were rejected, and the printer wrote a lambda whose body was a multi-line match with its arms at the column of the enclosing line.

Syntax

ys = xs.map(x => do
  y = x * 2
  if y > 10 then y else 0)

total = xs.fold(0, (acc, x) => do
  sq = x * x
  acc + sq)

Decisions

#DecisionWhy
1A lambda body may be do and an indented block, exactly as a function body. The block is indented deeper than the line the lambda starts on (the printer uses 2), and its last line is the valueThe rule models already follow for Python, Haskell and Scala bodies, and the same one SSPUR uses for fn, then do and arms, so there is nothing new to learn. Measured from the line, not from the (, so the lambda can sit anywhere on that line
2The closing ) (or ], }) goes right after the last line, which is the canonical form. A closer on its own line at any column is also accepted, as is a lambda on its own line after (, and further arguments after the block (apply(x => do … y * y, 3))These are the spellings models produce for JavaScript, Kotlin and Rust closures. Accepting them all costs one parser rule (a newline followed by a closer ends the block)
3Inside brackets, layout starts only after a line that ends in do, =>, then, else, = or a lambda’s {; elsewhere a bracket still joins linesMulti-line argument lists and records keep working unchanged, and every existing program lexes to the same tokens
4Tolerant input (stored canonically, with a note): x => and a block without do, x => { + block + } (when the next line is not a record field), and return e in tail position of a lambda, which becomes e. xs.map { x => ...} and a block not indented past its line are errors with a hint that shows the canonical formOne spelling in the codebase, while the first try of an agent used to braces still lands
5return anywhere but tail position inside a lambda stays E_RETURN_IN_LAMBDA, now with a hint (“a lambda’s value is its last line”)return exits the enclosing fn everywhere else; making it mean “return from the lambda” inside one would give a statement two meanings depending on nesting, and making it exit the function would need non-local exits through map in both tiers. A tail return has only one sensible meaning, so it is accepted
6A block lambda is an ordinary lambda: it captures by the existing rules (a captured var is boxed and shared, so total := total + x inside map updates the outer total), its effects join the caller’s row as before, and both tiers lower it unchangedNo new semantics, so no new parity risk; the closure suite (tests/programs/closures.ssp) runs them in both tiers
7Fusion treats a block of x = e bindings ending in an expression like the expression: traps and the vectorizer’s shape check accept it, and block-local names are not capturesA pure scalar block lambda in map(...).filter(...).sum fuses and vectorizes like its one-line form. No other body shape is affected, so the generated C for bench/native is byte-identical
8The printer writes a lambda whose body takes more than one line as x => do and the body indented 2 past the current line; a block body was already printed that wayOne canonical shape, which reparses to the same AST
9A trait method name used as a value where a function is expected, xs.map(area) or xs.fold(0, add), is checked as m__0 => m__0.area (one parameter per method parameter) when the expected function type’s first parameter is already known. The elaborator rewrites it into that lambda calling the impl for the type at the site (m__0 => area__Sq(m__0), or the built-in operation), so both tiers run the same trait-free programResolution stays static (ADR 0027, decision 6): the receiver type comes from the call that takes the function, never from a runtime value. It covers the common shapes (map, filter, fold, sort_by, a user higher-order fn with a concrete parameter type, and bounded generics) at no runtime cost
10Where the parameter type is not known yet (g = area, or app(area, x) with app[A](f: A -> Int, x: A)), the name stays E_UNKNOWN_NAME, with a hint that names both the working spelling xs.map(area) and the fallback x => x.area. A local, parameter, fn or constructor of the same name wins over the trait methodDeferring the check until other arguments fix the type would need a second inference pass for a rare case; the hint gets the agent there in one edit

Not done

  • return that exits the lambda from the middle of its block (decision 5).
  • Trait methods as values whose receiver type is fixed only by a later argument (decision 10), and trait-qualified names (Shape.area).