Closures
Expression Delimiters
rill has two expression delimiters with deterministic behavior:
| Delimiter | Semantics | Produces |
|---|---|---|
{ body } | Deferred (closure creation) | ScriptCallable |
( expr ) | Eager (immediate evaluation) | Result value |
Key distinction:
- Parentheses
( )evaluate immediately and return the result - Braces
{ }create a closure for later invocation (deferred execution)
Pipe Target Exception
When { } appears as a pipe target, it creates a closure and immediately invokes it:
5 -> { $ + 1 } # 6 (same observable result as eager evaluation)This is conceptually two steps happening in sequence:
5 -> { $ + 1 }
↓
Step 1: Create closure with implicit $ parameter
Step 2: Invoke closure with piped value (5) as argument
↓
Result: 6The observable result matches eager evaluation, but the mechanism differs. This matters when understanding error messages or debugging—the closure exists momentarily before invocation.
Comparison:
| Expression | Mechanism | Result |
|---|---|---|
5 -> { $ + 1 } | Create closure, invoke with 5 | 6 |
5 -> ($ + 1) | Evaluate expression with $ = 5 | 6 |
{ $ + 1 } => $fn | Create closure, store it | closure |
($ + 1) => $x | Error: $ undefined outside pipe | — |
The last row shows the key difference: ( ) requires $ to already be defined, while { } captures $ as a parameter for later binding.
Closure Syntax
# Block-closure (implicit $ parameter)
{ body }
# Zero-parameter closure (property-style, no parameters)
|| { body }
||body # shorthand for simple expressions
# With parameters
|x| { body }
|x|body # shorthand
|x, y| { body } # multiple parameters
|x: string| { body } # typed parameter
|x: number = 10| { body } # default value (type inferred)
|x: string = "hi"| { body } # default with explicit typeBlock-Closures
Block syntax { body } creates a closure with an implicit $ parameter. This enables deferred execution and reusable transformations.
Basic Block-Closure
{ $ + 1 } => $increment
5 -> $increment # 6
10 -> $increment # 11
$increment(7) # 8 (direct call also works)The block { $ + 1 } produces a closure. When invoked, $ is bound to the argument.
Block-Closures vs Zero-Param Closures
| Form | Params | isProperty | Behavior |
|---|---|---|---|
{ body } (block-closure) | [{ name: "$" }] | false | Requires argument |
||{ body } (zero-param) | [] | true | Auto-invokes on dict access |
{ $ * 2 } => $double
|| { 42 } => $constant
5 -> $double # 10 (requires argument)
$constant() # 42 (no argument needed)
# In dicts
[
double: { $ * 2 },
constant: || { 42 }
] => $obj
$obj.double(5) # 10 (explicit call required)
$obj.constant # 42 (auto-invoked on access)Block-closures require an argument; zero-param closures do not.
Type Checking
Block-closures check type at runtime:
{ $ + 1 } => $fn
type($fn) # "closure"
$fn(5) # 6
$fn("text") # Error: Cannot add string and numberMulti-Statement Block-Closures
Block-closures can contain multiple statements:
{
($ * 2) => $doubled
"{$}: doubled is {$doubled}"
} => $describe
5 -> $describe # "5: doubled is 10"Collection Operations
Block-closures integrate with collection operators:
[1, 2, 3] -> map { $ * 2 } # [2, 4, 6]
[1, 2, 3] -> filter { $ > 1 } # [2, 3]
[1, 2, 3] -> fold(0) { $@ + $ } # 6 ($@ is accumulator)Eager vs Deferred Evaluation
The choice between ( ) and { } determines when code executes:
# Eager: parentheses evaluate immediately
5 -> ($ + 1) => $result
$result # 6 (number, already computed)
# Deferred: braces create closure
{ $ + 1 } => $addOne
type($addOne) # "closure"
5 -> $addOne # 6
10 -> $addOne # 11 (invoked later with different value)
# Practical difference
(5 + 1) => $six # 6 (immediate)
{ $ + 1 } => $fn # closure (deferred)
$six # 6
10 -> $fn # 11Use ( ) when you want the result now. Use { } when you want reusable logic.
Late Binding
Closures resolve captured variables at call time, not definition time. This enables recursive patterns and forward references.
Basic Example
10 => $x
||($x + 5) => $fn
20 => $x
$fn() # 25 (sees current $x=20, not $x=10 at definition)Recursive Closures
|n| { ($n < 1) ? 1 ! ($n * $factorial($n - 1)) } => $factorial
$factorial(5) # 120The closure references $factorial before it exists. Late binding resolves $factorial when the closure executes.
Mutual Recursion
|n| { ($n == 0) ? true ! $odd($n - 1) } => $even
|n| { ($n == 0) ? false ! $even($n - 1) } => $odd
$even(4) # trueForward References
[
|| { $helper(1) },
|| { $helper(2) }
] => $handlers
|n| { $n * 10 } => $helper # defined after closures
$handlers[0]() # 10
$handlers[1]() # 20Variable Mutation Visibility
Closures see the current value of captured variables:
0 => $counter
|| { $counter } => $get
|| { $counter + 1 } => $getPlus1
5 => $counter
[$get(), $getPlus1()] # [5, 6]Dict-Bound Closures
Closures stored in dicts have $ late-bound to the containing dict at invocation (like this in other languages).
Zero-Arg Closures Auto-Invoke
[
name: "toolkit",
count: 3,
summary: || { "{$.name}: {$.count} items" }
] => $obj
$obj.summary # "toolkit: 3 items" (auto-invoked on access)Accessing Sibling Fields
[
width: 10,
height: 5,
area: || { $.width * $.height }
] => $rect
$rect.area # 50Parameterized Dict Closures
[
name: "tools",
greet: |x| { "{$.name} says: {$x}" }
] => $obj
$obj.greet("hello") # "tools says: hello"Reusable Closures Across Dicts
|| { "{$.name}: {$.count} items" } => $describer
[name: "tools", count: 3, str: $describer] => $obj1
[name: "actions", count: 5, str: $describer] => $obj2
$obj1.str # "tools: 3 items"
$obj2.str # "actions: 5 items"Calling Sibling Methods
[
double: |n| { $n * 2 },
quad: |n| { $.double($.double($n)) }
] => $math
$math.quad(3) # 12List-Stored Closures
Closures in lists maintain their defining scope. Invoke via bracket access:
[
|x| { $x + 1 },
|x| { $x * 2 },
|x| { $x * $x }
] => $transforms
$transforms[0](5) # 6
$transforms[1](5) # 10
$transforms[2](5) # 25Chaining List Closures
|n| { $n + 1 } => $inc
|n| { $n * 2 } => $double
5 -> @[$inc, $double, $inc] # 13: (5+1)*2+1Inline Closures
Closures can appear inline in expressions:
[1, 2, 3] -> map |x| { $x * 2 } # [2, 4, 6]
[1, 2, 3] -> filter |x| { $x > 1 } # [2, 3]
[1, 2, 3] -> fold(0) |acc, x| { $acc + $x } # 6Inline with Block Bodies
[1, 2, 3] -> map |x| {
($x * 10) => $scaled
"{$x} -> {$scaled}"
}
# ["1 -> 10", "2 -> 20", "3 -> 30"]Nested Closures
Closures can contain closures. Each captures its defining scope:
|n| { || { $n } } => $makeGetter
$makeGetter(42)() # 42Closure Factory Pattern
|multiplier| {
|x| { $x * $multiplier }
} => $makeMultiplier
$makeMultiplier(3) => $triple
$makeMultiplier(10) => $tenX
$triple(5) # 15
$tenX(5) # 50Nested Late Binding
1 => $x
|| { || { $x } } => $outer
5 => $x
$outer()() # 5 (inner closure sees updated $x)Parameter Shadowing
Closure parameters shadow captured variables of the same name:
100 => $x
|x| { $x * 2 } => $double
$double(5) # 10 (parameter $x=5 shadows captured $x=100)Scope Isolation
Loop Closures
Each loop iteration creates a new child scope. Capture variables explicitly to preserve per-iteration values:
# Capture $ into named variable for each iteration
[1, 2, 3] -> each {
$ => $item
|| { $item }
} => $closures
[$closures[0](), $closures[1](), $closures[2]()] # [1, 2, 3]Note: $ (pipeValue) is a context property, not a variable. Use explicit capture for closure access.
Conditional Branch Closures
10 => $x
true ? { || { $x } } ! { || { 0 } } => $fn
20 => $x
$fn() # 20 (late binding sees updated $x)Invocation Patterns
Direct Call
|x| { $x + 1 } => $inc
$inc(5) # 6
{ $ + 1 } => $inc
$inc(5) # 6 (block-closure)Pipe Call
|x| { $x + 1 } => $inc
5 -> $inc() # 6
{ $ + 1 } => $inc
5 -> $inc # 6 (block-closure, no parens needed)Block-closures work seamlessly with pipe syntax since $ receives the piped value.
Postfix Invocation
Call closures from bracket access or expressions:
[|x| { $x * 2 }] => $fns
$fns[0](5) # 10
[{ $ * 2 }] => $fns
$fns[0](5) # 10 (block-closure)
|| { |n| { $n * 2 } } => $factory
$factory()(5) # 10 (chained invocation)Method Access After Bracket (Requires Grouping)
["hello", "world"] => $list
# Use grouping to call method on bracket result
($list[0]).upper # "HELLO"
# Or use pipe syntax
$list[0] -> .upper # "HELLO"Note: $list[0].upper parses .upper as field access on $list, not as a method call on the element. This throws an error since lists don’t have an upper field.
Parameter Metadata
Closures expose parameter metadata via the .params property. This enables runtime introspection of function signatures.
Basic Usage
|x, y| { $x + $y } => $add
$add.params
# [
# x: [type: ""],
# y: [type: ""]
# ]Typed Parameters
|name: string, age: number| { "{$name}: {$age}" } => $format
$format.params
# [
# name: [type: "string"],
# age: [type: "number"]
# ]Block-Closures
Block-closures have an implicit $ parameter:
{ $ * 2 } => $double
$double.params
# [
# $: [type: ""]
# ]Zero-Parameter Closures
|| { 42 } => $constant
$constant.params
# []Practical Use Cases
Generic Function Wrapper:
|fn| {
$fn.params -> .keys -> .len => $count
"Function has {$count} parameter(s)"
} => $describe
|x, y| { $x + $y } => $add
$describe($add) # "Function has 2 parameter(s)"Validation:
|fn| {
$fn.params -> .entries -> each {
$[1].type -> .empty ? "Missing type annotation: {$[0]}" ! ""
} -> filter { !$ -> .empty }
} => $checkTypes
|x, y: number| { $x + $y } => $partial
$checkTypes($partial) # ["Missing type annotation: x"]Parameter Annotations
Parameters can have their own annotations using ^(key: value) syntax after the parameter name. These attach metadata to individual parameters for validation, configuration, or documentation purposes.
Syntax and Ordering
Parameter annotations appear in a specific order:
|paramName: type ^(annotations) = default| bodyOrdering rules:
- Parameter name (required)
- Type annotation with
:(optional) - Parameter annotations with
^()(optional) - Default value with
=(optional)
|x: number ^(min: 0, max: 100)|($x) => $validate
|name: string ^(required: true) = "guest"|($name) => $greet
|count ^(cache: true) = 0|($count) => $processAccess Pattern
Parameter annotations are accessed via .params.paramName.__annotations.key:
|x: number ^(min: 0, max: 100), y: string|($x + $y) => $fn
$fn.params
# Returns:
# [
# x: [type: "number", __annotations: [min: 0, max: 100]],
# y: [type: "string"]
# ]
$fn.params.x.__annotations.min # 0
$fn.params.x.__annotations.max # 100
$fn.params.y.?__annotations # false (no annotations on y)Validation Metadata
Use parameter annotations to specify constraints:
|value: number ^(min: 0, max: 100)|($value) => $bounded
$bounded.params.value.__annotations.min # 0
$bounded.params.value.__annotations.max # 100Generic validator pattern:
|fn, arg| {
$fn.params -> .entries -> .head -> *<$name, $meta>
$meta.?__annotations ? {
($arg < $meta.__annotations.min) ? "Value {$arg} below min {$meta.__annotations.min}" !
($arg > $meta.__annotations.max) ? "Value {$arg} above max {$meta.__annotations.max}" !
""
} ! ""
} => $validate
|x: number ^(min: 0, max: 10)|($x) => $ranged
$validate($ranged, 15) # "Value 15 above max 10"Caching Hints
Mark parameters that should trigger caching behavior:
|key: string ^(cache: true)|($key) => $fetch
$fetch.params.key.__annotations.cache # trueFormat Specifications
Attach formatting metadata to parameters:
|timestamp: string ^(format: "ISO8601")|($timestamp) => $formatDate
$formatDate.params.timestamp.__annotations.format # "ISO8601"Multiple Annotations
Parameters can have multiple annotations:
|email: string ^(required: true, pattern: ".*@.*", maxLength: 100)|($email) => $validateEmail
$validateEmail.params.email.__annotations.required # true
$validateEmail.params.email.__annotations.pattern # ".*@.*"
$validateEmail.params.email.__annotations.maxLength # 100Annotation-Driven Logic
Use parameter annotations to drive runtime behavior:
|processor| {
$processor.params -> .entries -> each {
$[1].?__annotations ? {
$[1].__annotations.?required ? "Parameter {$[0]} is required" ! ""
} ! ""
} -> filter { !$ -> .empty }
} => $getRequiredParams
|x, y: string ^(required: true), z|($x) => $fn
$getRequiredParams($fn) # ["Parameter y is required"]Checking for Annotations
Use existence check .?__annotations to determine if a parameter has annotations:
|x: number ^(min: 0), y: string|($x + $y) => $fn
$fn.params.x.?__annotations # true
$fn.params.y.?__annotations # falseAnnotation Reflection
Closures support annotation reflection via .^key syntax. Annotations attach metadata to closures for runtime introspection.
Type Restriction: Only closures support annotation reflection. Accessing .^key on primitives throws RUNTIME_TYPE_ERROR.
Basic Annotation Access
^(min: 0, max: 100) |x|($x) => $fn
$fn.^min # 0
$fn.^max # 100Complex Annotation Values
Annotations can hold any value type:
^(config: [timeout: 30, endpoints: ["a", "b"]]) |x|($x) => $fn
$fn.^config.timeout # 30
$fn.^config.endpoints[0] # "a"Default Value Coalescing
Use the default value operator for optional annotations:
|x|($x) => $fn
$fn.^timeout ?? 30 # 30 (uses default when annotation missing)
^(timeout: 60) |x|($x) => $withTimeout
$withTimeout.^timeout ?? 30 # 60 (uses annotated value)Annotation-Driven Logic
^(enabled: true) |x|($x) => $processor
$processor.^enabled ? "processing" ! "disabled" # "processing"Dynamic Annotations
Annotation values are evaluated at closure creation:
10 => $base
^(limit: $base * 10) |x|($x) => $fn
$fn.^limit # 100Error Cases
Undefined Annotation Key:
|x|($x) => $fn
$fn.^missing # Error: RUNTIME_UNDEFINED_ANNOTATIONNon-Closure Type:
"hello" => $str
$str.^key # Error: RUNTIME_TYPE_ERRORAll primitive types (string, number, boolean, list, dict) throw RUNTIME_TYPE_ERROR when accessing .^key.
Error Behavior
Undefined Variables
Undefined variables throw an error at call time (rill has no null):
|| { $undefined } => $fn
$fn() # Error: Undefined variable: $undefinedInvoking Non-Callable
[1, 2, 3] => $list
$list[0]() # Error: Cannot invoke non-callable value (got number)Type Errors
|x: string| { $x } => $fn
$fn(42) # Error: Parameter type mismatch: x expects string, got numberImplementation Notes
Scope Chain
Closures store a reference to their defining scope (definingScope). At invocation:
- A child context is created with
definingScopeas parent - Parameters are bound in the child context
- Variable lookups traverse: local → definingScope → parent chain
Memory Considerations
- Closures hold references to their defining scope
- Scopes form a tree structure (no circular references)
- Scopes remain live while referenced by closures
Performance
- Variable lookup traverses the scope chain at each access
- No caching (ensures mutation visibility)
- Closure creation is lightweight (stores reference, not copy)
See Also
- Reference — Language specification
- Collections —
each,map,filter,foldwith closures - Guide — Getting started tutorial