2: Language Semantics — Patterns and Syntax
Contents
- 2.14: Destructuring, Spread, and Shorthand Syntax
- 2.14.1: Map and Struct Destructuring
- 2.14.2: List Destructuring and Spread
- 2.14.3: Tuple Destructuring
- 2.14.4: Function Parameter Destructuring
- 2.14.5: Shorthand Initialization
- 2.14.6: The
_Discard Binding - 2.14.7: Spread in Collection Literals
- 2.15: Comments
- 2.16: Variadic Functions
- 2.17: Evaluation Order
2.14: Destructuring, Spread, and Shorthand Syntax
Leaf supports destructuring in variable bindings and function parameters, spread syntax in list destructuring and collection literals, shorthand initialization for maps and structs, and struct field destructuring.
2.14.1: Map and Struct Destructuring
A map value can be destructured into individual bindings using {...} syntax on the left side of an assignment:
m = {x: 10, y: 20, z: 30}
{x, y} = m # x = m["x"], y = m["y"]
Map destructuring uses bracket access, so x and y have type int (the map's value type). If any key is not present, it is a runtime error (panics).
Map destructuring requires str keys. Shorthand map destructuring (e.g., {x, y} = m) is only valid when the map's key type is str. Each name in the pattern is used as a string key for bracket access. Non-string-keyed maps (e.g., Map[int, str]) cannot be destructured with this syntax — use explicit bracket access instead.
Additionally, only map entries whose keys are valid identifiers (see §2.1) can be destructured with shorthand syntax. A key like "fooBar" works because fooBar is a valid identifier; a key like "foo-bar" or "123" cannot be bound via shorthand destructuring because those strings are not valid identifiers. Use explicit bracket access for such keys: val = m["foo-bar"].
Mutability applies:
mut {x, y} = m # mut x = m["x"], mut y = m["y"]
x += 1 # ok — x is mutable
Struct destructuring:
Struct values use the same {...} syntax. The type checker disambiguates based on the expression's type:
struct Point
pub x: int
pub y: int
end
p = Point { x: 10, y: 20 }
{x, y} = p # x = p.x, y = p.y
Struct destructuring follows visibility rules: only fields visible to the calling code can be destructured. Private fields are accessible anywhere within the defining module:
struct Point
x: int # private
y: int # private
pub fn clone(other: Point) -> Self
{x, y} = other # ok — same module as the struct
return Self { x, y }
end
end
Mutability applies to struct destructuring just as it does for maps:
p = Point { x: 10, y: 20 }
mut {x, y} = p # mut x = p.x, mut y = p.y
x += 5 # ok — x is mutable
Partial destructuring is allowed:
{width, height} = someRect # only extract width and height
2.14.2: List Destructuring and Spread
items = [1, 2, 3, 4, 5]
[first, second] = items # first = items[0], second = items[1]
[head, ...tail] = items # head = items[0], tail = items[1..]
The ... spread operator collects remaining elements into a new list. It must appear on the last element:
[a, b, ...rest] = [10, 20, 30, 40]
# a = 10, b = 20, rest = [30, 40]
If the list has fewer elements than the pattern requires (excluding the spread element), it is a runtime error (panic). The spread element collects zero or more remaining elements — an empty spread result is valid:
[a, b, c] = [10, 20] # panic: list has 2 elements, pattern expects 3
[first, ...rest] = [] # panic: list has 0 elements, pattern expects at least 1
[a, b, ...rest] = [10, 20] # ok: a = 10, b = 20, rest = []
This is consistent with out-of-bounds bracket access (items[i]) being a runtime panic.
2.14.3: Tuple Destructuring
Tuples support destructuring on the left side of an assignment using parenthesized patterns (see also structs/data-types.md §4.7):
(x, y) = (10, 20)
(name, age, active) = ("Alice", 30, true)
The number of bindings must match the tuple's arity — a 2-tuple requires exactly two bindings, a 3-tuple requires three, and so on. Mismatched arity is a compile error.
Mutability applies:
mut (x, y) = (10, 20) # mut x = 10, mut y = 20
x += 5 # ok — x is mutable
The _ placeholder discards individual elements:
(_, second) = (1, "hello") # second = "hello", first element discarded
(a, _, c) = (1, 2, 3) # a = 1, c = 3
Tuple destructuring in for loops. Tuple destructuring is especially useful in for loops when iterating over types that yield tuples. Since Map[K, V] satisfies Iterable[Tuple[K, V]], maps can be iterated with tuple destructuring:
for (k, v) in {a: 1, b: 2}
println("{k} = {v}")
end
for (i, item) in items.enumerate()
println("{i}: {item}")
end
See types.md §3.4 for the full for loop destructuring rules.
2.14.4: Function Parameter Destructuring
Map, list, struct, and tuple destructuring can all be used in function parameters with type annotations:
fn distance({x, y}: Map[str, float]) -> float
return (x.pow(2.0) + y.pow(2.0)).pow(0.5)
end
fn firstAndRest([first, ...rest]: List[int]) -> int
return first
end
fn magnitude({x, y}: Point) -> float
return (x * x + y * y).toFloat().pow(0.5)
end
fn distance((x, y): Tuple[float, float]) -> float
return (x.pow(2.0) + y.pow(2.0)).pow(0.5)
end
Destructured parameters follow the same mutability rules as variable binding destructuring: without mut, the extracted bindings are immutable; with mut, all extracted bindings are mutable.
fn swapAndSum(mut (x, y): Tuple[int, int]) -> int
x += y
y = x - y
x = x - y
return x + y
end
2.14.5: Shorthand Initialization
When a variable in scope matches a map key or struct field name, the value can be omitted:
x = 10
y = 20
# Map shorthand
m = {x, y} # equivalent to {x: x, y: y}
# Struct shorthand
p = Point { x, y } # equivalent to Point { x: x, y: y }
# Can mix shorthand and explicit
m2 = {x, y, z: 30}
p2 = Point { x, y: 0 }
Shorthand and destructuring are symmetric:
{x, y} = someMap # destructuring (left of =)
newMap = {x, y} # shorthand init (right of =)
2.14.6: The _ Discard Binding
The identifier _ is a discard placeholder. It can be used wherever a binding name is expected, and causes the corresponding value to be ignored. Using _ as an expression (reading from it) is a compile error.
In destructuring:
[a, _, c] = [10, 20, 30] # a = 10, c = 30, second element discarded
(_, second) = (1, "hello") # second = "hello", first element discarded
Note: _ is not meaningful in map destructuring. Map destructuring is key-based and already partial — simply omit any keys you don't need (e.g., {x} = someMap extracts only key "x").
In for loops:
for _ in items # iterate but ignore the element (just count)
count += 1
end
for (_, v) in myMap # discard key, bind value
println("{v}")
end
for (k, _) in myMap # discard value, bind key
println("{k}")
end
In function parameters:
fn onClick(_: Event) # parameter type required, name discarded
println("clicked")
end
Multiple _ bindings are allowed in the same scope — unlike normal variable names, they do not conflict:
[_, _, third] = [1, 2, 3] # both discards are fine
_ is not a variable. It cannot be read:
_ = 42
println(_) # error: _ is not a valid expression
2.14.7: Spread in Collection Literals
The ... spread operator can be used inside list and map literals to inline the contents of another collection of the same kind.
List spread:
base = [1, 2, 3]
extended = [...base, 4, 5] # [1, 2, 3, 4, 5]
Multiple spreads and individual elements can be freely mixed:
a = [1, 2]
b = [3, 4]
combined = [...a, 0, ...b, 5] # [1, 2, 0, 3, 4, 5]
The spread source must be a List[T]. The element type of the spread source must match the element type of the surrounding literal — no implicit conversion is performed:
ints = [1, 2, 3]
strs = ["a", "b"]
mixed = [...ints, ...strs] # error: List[int] vs List[str]
Spreading into an empty literal copies the list:
copy = [...original] # new List[T] with same elements
Map spread:
defaults = {host: "localhost", port: "8080"}
overrides = {...defaults, port: "3000"} # {host: "localhost", port: "3000"}
When keys collide, later entries win — whether from a spread or an explicit key-value pair:
a = {x: 1, y: 2}
b = {y: 3, z: 4}
merged = {...a, ...b} # {x: 1, y: 3, z: 4}
The spread source must be a Map[K, V]. Both the key type and value type must match the surrounding literal. Spreading a struct, list, or other type into a map is not supported — only maps can be spread into maps.
m1 = {a: 1, b: 2} # Map[str, int]
m2 = {c: "x"} # Map[str, str]
bad = {...m1, ...m2} # error: Map[str, int] vs Map[str, str]
Map spread works with computed keys:
base = {[1]: "one", [2]: "two"} # Map[int, str]
extended = {...base, [3]: "three"} # Map[int, str]
Spread performs a shallow copy. Spreading a list or map creates a new container, but the elements/values remain references to the same objects. For nested collections, modifying an element in the result also affects the original:
a = [[1, 2]]
b = [...a]
b[0].push(3) # Also modifies a[0]
Mutability constraints. Spread respects mutability:
- Spreading a non-mutable source into a mutable binding is a compile error
- Spreading a mutable source into a mutable binding is allowed
- Spreading a non-mutable source into a non-mutable binding is allowed
- Spreading a mutable source into a non-mutable binding is allowed
The mutability check traces the root binding: if the spread source is a named binding, it must be mut; if it is a field access chain like a.items, the root binding a must be mut (see core.md §2.2). Expressions not rooted at a named binding (literals, function calls) are always permitted. This makes the check local and predictable.
items = [1, 2, 3] # immutable
mut result = [...items] # ERROR: cannot spread non-mut into mut
mut items = [1, 2, 3] # mutable
mut result = [...items] # OK: mut into mut
items = [1, 2, 3] # immutable
result = [...items] # OK: non-mut into non-mut
mut items = [1, 2, 3] # mutable
result = [...items] # OK: mut into non-mut
The check applies to each spread source independently. In a literal with multiple spreads, each source must satisfy the constraint:
mut a = [1, 2]
b = [3, 4]
mut combined = [...a, ...b] # ERROR: b is non-mut, cannot spread into mut
Rationale for mutability restriction: The restriction preventing spread of non-mutable sources into mutable bindings exists to prevent aliasing violations. Since spread performs a shallow copy, the elements in the new container are references to the same objects as the original container. If spreading an immutable source into a mutable binding were allowed, the mutable binding could be used to mutate elements that the holder of the immutable source expects to never change:
struct Counter
pub count: int
pub fn increment(mut self)
self.count += 1
end
end
items = [Counter { count: 0 }] # immutable binding
# If this were allowed:
# mut result = [...items] # ERROR: both would reference same Counter
# result[0].increment() # would mutate items[0] unexpectedly
Even though the new container is mutable, the elements were obtained from a non-mutable context, and allowing mutation through the new binding would violate the immutability guarantee of the original binding. For primitive types (which are copied by value), this would be safe, but Leaf uses reference semantics for all non-primitive types, so the restriction applies uniformly.
To create a mutable copy of an immutable collection where mutation is intended, spread from a mutable source or use explicit element-by-element copying where deep copying is needed.
Spread mutability check applies at binding sites only. The mutability constraint described above applies only at variable binding sites — that is, assignments of the form [mut] name = [...]. It does not apply when a spread literal is passed directly as a function argument, even if the parameter is declared mut:
items = [1, 2, 3] # non-mut
fn process(mut data: List[int])
data.push(4)
end
process([...items]) # OK: mutability check does not apply here
The rationale is that the spread creates a new, independent collection. Parameter mutability is governed by the aliasing model (§2.2), not by the spread mutability rule. Applying the check at call sites would create an inconsistency where introducing a temporary variable changes behavior:
temp = [...items] # OK: non-mut into non-mut
process(temp) # OK: passing non-mut to mut parameter
# The direct form should behave identically:
process([...items]) # OK
Spread in literals vs call sites. Inside [...] list literals and {...} map literals, ...expr spreads a collection inline. The ... prefix is also valid at function call sites to spread a List[T] into a variadic parameter (see §2.16). Outside these two contexts — including tuple literals and any other expression position — ... is not valid syntax.
2.15: Comments
Comments begin with # and extend to the end of the line. Everything from the # character to the next newline is ignored by the compiler:
# This is a full-line comment
x = 42 # This is an inline comment
There are no block comments or multiline comment delimiters. To comment out multiple lines, each line must begin with (or contain) its own #:
# This function is temporarily disabled.
# fn oldVersion(x: int) -> int
# return x + 1
# end
A # inside a string literal is not treated as a comment:
message = "use # for comments" # the # inside the string is literal
There is no special syntax for doc comments. Documentation tooling, if any, is outside the scope of this specification.
2.16: Variadic Functions
A variadic function accepts a variable number of arguments of the same type. The trailing parameter is declared with ... before its type:
fn log(prefix: str, args: ...str)
for arg in args
println("{prefix}: {arg}")
end
end
log("INFO", "server started", "port 8080", "ready")
Syntax. A variadic parameter is written as name: ...T. It must be the last parameter in the list, and only one variadic parameter per function is allowed. Variadic parameters are supported in all function forms: top-level functions, struct methods, interface methods, and closures.
Body representation. Inside the function body, the variadic parameter has type List[T]. All standard List[T] operations are available — iteration, indexing, .len(), and so on.
Call-site arguments. Callers pass zero or more arguments of type T for the variadic parameter. Passing no arguments results in an empty List[T]:
log("INFO") # args = []
log("INFO", "hello") # args = ["hello"]
log("INFO", "a", "b", "c") # args = ["a", "b", "c"]
Call-site spread. A List[T] can be spread into a variadic parameter using the ... prefix at the call site:
messages = ["server started", "port 8080"]
log("INFO", ...messages) # equivalent to log("INFO", "server started", "port 8080")
Spread and individual arguments can be freely mixed, as long as all arguments are of type T:
log("INFO", "header", ...messages, "footer")
The ...expr spread at a call site is only valid for a variadic parameter — it cannot be used to pass arguments to a non-variadic parameter.
Generics. Variadic parameters can use generic type parameters. All arguments at the call site must have the same type; heterogeneous argument lists are a type error:
fn printAll[T: ToString](args: ...T)
for arg in args
println(arg.toString())
end
end
printAll(1, 2, 3) # T inferred as int
printAll("a", "b") # T inferred as str
printAll(1, "b") # error: mixed int and str
Function types. Variadic functions can be expressed as function types using ...T for the final parameter type:
type Logger = fn(...str) -> ()
type Transformer = fn(str, ...int) -> str
fn apply(f: fn(...int) -> int, args: ...int) -> int
f(...args)
end
A fn(...T) -> R type is distinct from fn(List[T]) -> R. Only a variadic function (or closure) satisfies fn(...T) -> R; a function that takes an explicit List[T] parameter does not, and vice versa.
2.17: Evaluation Order
Leaf uses strict left-to-right evaluation for all sub-expressions. When an expression contains multiple sub-expressions that must be evaluated, they are evaluated in the order they appear in the source text, from left to right. This applies to:
- Function arguments: In
f(a(), b(), c()),a()is evaluated first, thenb(), thenc(), thenfis called. - List literal elements: In
[a(), b(), c()], elements are evaluated left to right. - Map literal entries: In
{[k1()]: v1(), [k2()]: v2()},k1()is evaluated first, thenv1(), thenk2(), thenv2(). Each key is evaluated before its corresponding value, and entries are evaluated left to right. This is consistent with the "later entries win" rule for duplicate keys — the rightmost entry's value is retained. - Struct literal fields: In
Point { x: a(), y: b() },a()is evaluated beforeb(). - Binary operator operands: In
a() + b(), the left operanda()is evaluated before the right operandb(). - Compound assignment: In
x[a()] += b(), the left-hand side (x[a()]) is evaluated before the right-hand side (b()).
Short-circuit operators (&& and ||) evaluate left to right but may skip the right operand entirely — this is not an exception to left-to-right order, but a consequence of it. See §2.12 for details.
Spread expressions at call sites and in collection literals are evaluated in left-to-right order along with the other arguments or elements.