hanki

13. Closures

A closure is written |params| body. An inline body is a single expression; a trailing-block body, closed by end, is a statement sequence with the same semantics as do…end (§8) - the do is implicit between |x| and end. There is no do/do! keyword to write and no closure-level effect marker - effectfulness is inferred from the body and gated at the call site by the called method's bang.

Two layouts, one closure:

use io

def main!() -> () [io]
  [1, 2, 3].map(|x| x * 2)    # inline: the `)` bounds the body, no `end`

  [1, 2, 3].each! |x|         # trailing block: the `end` closes it
    io.print!("#{x}\n")
  end
end

Effectfulness is inferred, not spelled. A pure combinator (map, find, filter - no bang) checks the closure body in pure context: an action call inside is rejected. An action combinator (each! - has bang) lets the body call actions and propagates their effects to the caller (§6). The bang on the method already says which one applies, so the closure needs no marker of its own.

open io
open list

def demo!(xs: List<i32>) -> () [io]
  xs.map(|x| io.print!("#{x}"))    # error: map is pure; its closure cannot call an action
  xs.each!(|x| io.print!("#{x}"))  # ok: each! is an action; [io] propagates to the caller
end

A let-bound closure (not in call position) infers its effects from its body, and those effects flow to wherever it is later called. If the binding (or a struct field, or a return type) carries an explicit function type, that type's effect row bounds the closure: an unannotated function type is pure, so storing an effectful closure there requires annotating the row - g: (i32) -> () [io] = |x| io.print!(x) (see §6, Effect-annotated function types).

Parameter types

Closure parameter types are inferred bidirectionally from the expected function type at the call site - typically a higher-order combinator like list.map. No annotation is needed when there is one:

[1, 2, 3].map(|x| x * 2)    # x: i32, from List<i32>.map's signature

When there is no expected type - a closure bound to an unannotated let with no other constraint - inference cannot pin the parameters and the compiler reports cannot infer type, the same diagnostic as §11. Same fix too: annotate either the binding or the parameters.

f: (i32) -> i32 = |x| x * 2
g               = |x: i32| x * 2

Body complexity

An inline closure body is exactly one expression - a grammar fact, not a lint: a second statement before the closing ) or , is a parse error. A genuine sequence fits via a do … end block (§8), which is itself one expression. A trailing-block body is already a do…end block, so it sequences directly - no inner do needed; this is the form for builder and customization blocks. Whichever form, keep the logic small: a nested loop or a match with three or more arms anywhere in the body is flagged; extract it into a named def (or def!) so the call site reads as list.map(items, transform) rather than carrying the logic inline. Closures are for one-shot predicates, tiny transformations, and short builder sequences; once the logic grows past that, the naming pressure of def pays off.

No brace-block closures. Ruby's { |x| x*2 } is not allowed in Hanki - Ruby's precedence footgun is well known. |x| body is the only form.