3: Type System
Contents
3.1: Pattern Matching
Pattern matching destructures enum variants and matches primitive values. Patterns bind associated data or matched values to named variables.
Patterns can be nested. Each position in a destructuring pattern can be a variable name, _, or another pattern. This allows matching through multiple layers of enums in a single arm:
fn unwrapNested(value: Option[Result[int, str]]) -> int
match value
Some(Ok(n)) then n
Some(Err(e)) then panic("error: {e}")
None then panic("missing")
end
end
Nesting works with the matches operator as well:
if value matches Some(Ok(n))
println("got: {n}")
end
match expression:
The match expression destructures a value exhaustively. It works with enum types and primitive types (bool, byte, int, uint, float, str). See §3.5.2 for the expression semantics of match (value production and typing rules).
fn greet(name: str?)
match name
Some(n) then println("Hello {n}")
None then println("Hello stranger")
end
end
fn process(result: Result[int, str])
match result
Ok(value) then println("got: {value}")
Err(e) then println("error: {e}")
end
end
Each arm consists of a pattern and a body introduced by then. The body follows the standard block body rule (§2.0.2): an inline expression on the same line, or a multi-statement block terminated by end. Multi-statement arm bodies each require their own end; the outer end closes the match construct:
match result
Ok(value) then
validated = validate(value)
println("got: {validated}")
end
Err(e) then
log(e)
println("error: {e}")
end
end
match must be exhaustive. Every variant of the enum must be covered. The compiler reports an error if any variant is missing. A wildcard _ arm matches any remaining variants:
match genResult
Yielded(value) then println("got: {value}")
_ then println("generator finished")
end
The matches operator — non-exhaustive pattern matching:
The matches operator tests whether a value matches a pattern, returning bool. When used as the condition of if, elseif, or while, it also introduces pattern variable bindings into the guarded body.
if result matches Ok(value)
println("got: {value}")
end
matches chains naturally with elseif:
if result matches Ok(value)
println("got: {value}")
elseif result matches Err(e)
println("error: {e}")
end
Mixed chains. Boolean conditions and matches can be freely mixed in the same if/elseif chain. Each branch independently uses either a boolean condition or a matches pattern:
if result matches Ok(value)
println("got: {value}")
elseif fallbackAvailable
println("using fallback")
elseif result2 matches Err(e)
println("error: {e}")
else
println("default")
end
While loops. matches can be used as a while condition, re-evaluating each iteration with bindings scoped to the loop body:
while iter.next() matches Some(item)
process(item)
end
Boolean composition. Because matches is a boolean operator at comparison precedence (see semantics/values.md §2.12.2), it composes with && and ||:
if result matches Ok(value) && value > 0
println("positive: {value}")
end
When matches appears on the left side of &&, pattern bindings are available on the right side (short-circuit evaluation guarantees the match succeeded).
Referencing pattern bindings from matches across || is a compile error, since the match may not have succeeded — unless both sides of || bind the same name with the same type. In that case, the binding is guaranteed to be initialized regardless of which branch succeeded, and it is available in the guarded body:
# OK — both branches bind `x` as int, so `x` is always available
if a matches Some(x) || b matches Some(x)
println("{x}")
end
# Compile error — only one side binds `x`
if a matches Some(x) || b matches None
println("{x}")
end
# Compile error — names match but types differ
if a matches Ok(x) || b matches Some(x) # x: int vs x: str
println("{x}")
end
When the || condition is true and both branches matched, the binding takes its value from the left branch (short-circuit evaluation — the right branch is not evaluated if the left succeeds). When only the right branch matched, the binding takes its value from the right branch.
Binding scope and mutability rules:
- Variable bindings introduced by
matcharms andmatchesconditions are always immutable. There is nomutsyntax for pattern bindings. To obtain a mutable copy, rebind withmutinside the arm or guarded body (e.g.,mut val = v). - Variable bindings introduced by
matchesare scoped to the guarded body (ifbody,elseifbody, orwhileloop body). - Bindings are not available in
elsebranches or afterend. - Named binding patterns (e.g.,
Ok(value)) may only appear whenmatchesis the direct condition ofif,elseif, orwhile. In other positions, only non-binding patterns are allowed (_, literals, bare variant names):
# OK anywhere — no bindings introduced
isOk = result matches Ok(_)
isNone = opt matches None
# OK — bindings scoped to if body
if result matches Ok(value)
println("{value}")
end
# Compile error — binding pattern with no guarded scope
x = result matches Ok(value)
Nested patterns destructure enums with named fields:
match event
Event.Click(x, y) then println("clicked at {x}, {y}")
Event.KeyPress(key) then println("key: {key}")
_ then println("other event")
end
Simple enums (no associated data) can use either == for variant comparison or match:
if d == Direction.North
println("heading north")
end
match d
Direction.North then println("heading north")
Direction.South then println("heading south")
_ then println("other direction")
end
Pattern matching works on simple enums even though there is no data to destructure.
Variant qualification: In match arms, variant names are normally qualified with the enum type name: Direction.North, Shape.Circle(r), Event.Click(x, y). A small set of prelude enum variants (Some, None, Ok, Err, Yielded, Done) are brought into scope by the prelude and may be written unqualified. All other prelude enum variants (e.g. Ordering.Less, IoError.Eof) require qualification like any user-defined enum. To use other variants unqualified, import them with use Enum.* (see lang/modules.md §6.3). The same rules apply in matches patterns.
Bare identifier disambiguation: When a bare identifier appears in a pattern position, the compiler uses name lookup to determine its meaning. If the name resolves to an in-scope enum variant (via the prelude, use Enum.*, or qualified naming), it is interpreted as a variant pattern. If the name does not resolve to any variant, it is interpreted as a catch-all binding. This uses the same name lookup rules as expressions (§2.1). As a consequence, a misspelled variant name silently becomes a catch-all binding — exhaustiveness checking (below) helps catch this: the real variant becomes unreachable, producing a compiler warning or error depending on the context.
Matching on primitive types. In addition to enums, match can be used with bool, byte, int, uint, float, and str values. Arms use literal patterns to match specific values:
fn describe(n: int) -> str
match n
0 then "zero"
1 then "one"
_ then "other"
end
end
fn toYesno(b: bool) -> str
match b
true then "yes"
false then "no"
end
end
fn handleCommand(cmd: str) -> str
match cmd
"quit" then "goodbye"
"help" then "available commands: quit, help"
_ then "unknown command: {cmd}"
end
end
Exhaustiveness for primitives:
bool: Exhaustive when bothtrueandfalseare covered (or a_wildcard is present).byte,int,uint,float,str: These types have unbounded domains and cannot be fully enumerated. A_wildcard arm is required unless the compiler can verify that all values are covered (which in practice means_is always needed).
Literal patterns support the same forms as literal expressions: decimal, hex, binary, and octal for integers; floating-point literals for float; and double-quoted strings for str. Numeric literal patterns may be preceded by - to match negative values (e.g., -1, -3.14). The _ wildcard binds no value. A bare name in a primitive match arm is treated as a catch-all binding (like _ but captures the value):
fn classify(n: int) -> str
match n
0 then "zero"
1 then "one"
other then "number: {other}"
end
end
matches works with literal patterns as well:
if cmd matches "quit"
shutdown()
end
Matching on tuples. Tuple patterns destructure each element positionally. Each position in the pattern can be a literal, a binding name, _, or a nested pattern. Tuple patterns are valid in both match arms and matches conditions:
match pair
(0, 0) then "origin"
(0, y) then "y-axis at {y}"
(x, 0) then "x-axis at {x}"
(x, y) then "({x}, {y})"
end
Exhaustiveness for tuples: A tuple pattern covers a value when every element position is covered. A _ wildcard or binding name in a position covers all values of that element's type. Tuple exhaustiveness is checked element-wise — a match on Tuple[A, B] is exhaustive when the cross-product of per-element coverage spans all possibilities. In practice, a single arm with _ or a binding in every position (e.g., (_, _) or (x, y)) is a catch-all that makes the match exhaustive:
match pair
(0, _) then "first is zero"
(_, _) then "other" # catch-all — exhaustive
end
When tuple elements are enums, exhaustiveness follows from covering all variant combinations:
match (optA, optB)
(Some(a), Some(b)) then "both: {a}, {b}"
(Some(a), None) then "only a: {a}"
(None, Some(b)) then "only b: {b}"
(None, None) then "neither"
end
Matching on lists. List patterns match by length and destructure elements positionally. A rest pattern ...name binds name to a List[T] containing the remaining elements (zero or more). ..._ discards the remaining elements. List patterns are valid in both match arms and matches conditions:
match items
[] then "empty"
[only] then "single: {only}"
[first, second] then "pair: {first}, {second}"
[head, ...tail] then "head: {head}, {tail.length()} more"
end
Exhaustiveness for lists: Lists have dynamic length, so their domain cannot be fully enumerated by fixed-length patterns. A _ wildcard, a catch-all binding, or a rest pattern ([...rest] or [head, ...rest]) is required for exhaustiveness. Without one, the compiler reports a non-exhaustive match error — analogous to int and str:
# Exhaustive — rest pattern covers all remaining lengths
match items
[] then "empty"
[x, ...rest] then "non-empty, starts with {x}"
end
# Exhaustive — wildcard catch-all
match items
[] then "empty"
[x] then "singleton"
_ then "multiple"
end
A bare rest pattern [...rest] matches any list (including empty), binding all elements. [..._] matches any list and discards the elements.
Matching on maps. Map patterns match by key presence and bind the corresponding values. Map patterns are only valid when the matched value has type Map[str, V]. Non-string-keyed maps cannot be matched with map patterns. Keys in map patterns are bare identifiers (matching string keys) consistent with map literal syntax:
match config
{host, port} then connect(host, port)
{host} then connect(host, 8080)
_ then connect("localhost", 8080)
end
Exhaustiveness for maps: Maps have an unbounded key set, so map patterns are always non-exhaustive. A _ wildcard or catch-all binding is required in any match that includes map patterns. The compiler reports a non-exhaustive match error if no catch-all is present. An empty map pattern {} matches any map (it imposes no key requirements), but the exhaustiveness checker does not recognize it as a catch-all — use _ or a binding name instead.
A map pattern {a, b} matches when the map contains keys "a" and "b" (and possibly others). It does not require an exact match — extra keys are ignored. The bound variables receive the values associated with the matched keys. If a required key is absent, the arm does not match.
matches works with map patterns for non-exhaustive testing:
if config matches {host, port}
connect(host, port)
end
Summary:
match: exhaustive pattern matching on enum variants, primitive values, tuples, lists, and maps, binding inner values to named variables.matches: non-exhaustive boolean operator for testing a single pattern, with optional variable bindings when used inif/elseif/whileconditions.- Simple enums: use
==for comparison ormatchwith qualified names. - Primitives: use literal patterns with a
_or catch-all binding for exhaustiveness (boolcan be exhaustive withtrue+false). - Tuples: match element-wise; exhaustive when all element positions are covered.
- Lists: match by length; always require
_, a catch-all binding, or a rest pattern for exhaustiveness. - Maps: match by key presence; always require
_or a catch-all binding for exhaustiveness.
3.2: The never Type
never is the bottom type — no value has type never, and never is assignable to every type:
fn fail(message: str) -> never
panic("fatal: {message}")
end
x: int = fail("unreachable") # ok — never is assignable to int
Key properties of never:
neveris assignable to every type.- No type is assignable to
never(exceptneveritself). - Functions with return type
nevermust not return normally. neverarises from divergent expressions such aspanic.- For any generic type
G,G[never, ..., never]is assignable to anyG[T1, ..., Tn](the bottom-type exception — seelang/semantics/functions.md§2.6.3). - Because
neveris uninhabitable, it trivially satisfies every interface constraint. For any interfaceI,neverconforms toI. This follows from vacuous truth — no value of typeneverexists to violate the contract. This makes constrained generic types likeSet[never]valid, enabling patterns such asSet.from([])where[]infersList[never].
3.3: Generators
A generator function is declared with the gen fn keyword and a return type of Generator[Y, R, N]:
Y— the yield type: what the generator produces viayieldR— the return type: what the generator produces viareturnN— the next type: what the caller passes in via.next(Some(value))
Generator is declared as Generator[Y, R=(), N=never], using default type parameters (see lang/structs/core.md §4.4). This means Generator[int] is shorthand for Generator[int, (), never], and Generator[int, str] is shorthand for Generator[int, str, never].
gen fn countdown(from: int) -> Generator[int]
mut i = from
while i > 0
yield i
i -= 1
end
end
gen fn vs fn:
The gen fn keyword is the syntactic marker that identifies a generator function. It is required — the compiler does not infer generator status from the presence of yield or from the return type alone.
gen fndeclares a generator function. Its body may containyield. When called, it does not execute immediately — it returns a suspendedGenerator[Y, R, N]value that produces results lazily.fndeclares a normal function. A normal function may have a return type ofGenerator[Y, R, N], but only if it returns a generator value obtained from elsewhere (e.g., by calling agen fnor receiving one as a parameter). Usingyieldinside a normalfnis a compile error.
gen fn is not valid in anonymous function expressions. Generator functions must be named — either as top-level/nested function declarations or struct methods. Anonymous functions (lambdas) use fn only; gen fn(x) yield x is a compile error. See semantics/functions.md §2.4 for anonymous function syntax.
# A normal function that returns a generator it received — no `gen fn` needed
fn takeFirst(g: Generator[int]) -> Generator[int]
# Cannot use yield here — this is a normal fn
return g
end
Visibility modifiers precede gen fn the same way they precede fn:
pub gen fn countdown(from: int) -> Generator[int]
# ...
end
pkg gen fn internalSequence() -> Generator[str]
# ...
end
Inside a struct body, gen fn works the same way:
struct Range
pub start: int
pub stop: int
pub gen fn iter(self) -> Generator[int]
mut i = self.start
while i < self.stop
yield i
i += 1
end
end
end
yield is an expression. It produces a value of type Y to the caller and suspends execution. The expression evaluates to the value of type N passed in by the caller on the next .next(Some(value)) call. A bare yield without an operand yields () (the unit value) and is valid only when Y = (). This is analogous to return without an operand, which returns ().
gen fn echo() -> Generator[str, (), str]
mut input = yield "ready"
while true
input = yield "echo: {input}"
end
end
Caller API:
The caller drives the generator via .next(value: Option[N]):
fn next(mut self, value: Option[N]) -> GeneratorResult[Y, R]
mut gen = countdown(5)
result = gen.next(None) # result: GeneratorResult[int, ()]
gen.next(None) returns GeneratorResult[Y, R], which is:
enum GeneratorResult[out Y, out R]
Yielded(Y)
Done(R)
end
When the generator yields, the result is Yielded(value). When the generator returns (or the body completes), the result is Done(returnValue).
First call: The first call to .next() must pass None. This is because the generator body starts from the beginning — no yield expression has been reached yet, so there is no yield to inject a value into. Passing Some(...) on the first call is a runtime error (panics).
Subsequent calls: After the generator has yielded at least once:
N = never:valuemust beNone. This is statically enforced — no value of typeSome(never)can be constructed, so passingSome(...)is a compile error.N ≠ never:valuemust beSome(v)wherev: N. Thevbecomes the result of the most recentyieldexpression inside the generator. PassingNoneon a subsequent call to a generator withN ≠ neveris a runtime error (panics).
Two-way communication (N ≠ never):
For generators with N ≠ never, .next(Some(value)) passes a value into the generator on every call after the first:
gen = echo()
r1 = gen.next(None) # starts the generator
# body runs until first yield; r1 = Yielded("ready")
r2 = gen.next(Some("hello")) # resumes from yield; input = "hello"; r2 = Yielded("echo: hello")
r3 = gen.next(Some("world")) # resumes from yield; input = "world"; r3 = Yielded("echo: world")
Generators as iterables:
Generators with default R and N (() and never respectively) are automatically iterable. Generator[T, (), never] satisfies both Iterator[T] and Iterable[T]:
Iterator[T]conformance: The generator's built-innext()method translatesYielded(value)toSome(value)andDone(())toNone, matching theIterator[T]interface.Iterable[T]conformance: The built-initer()method returnsself.
This means generators can be used directly in for loops:
for i in countdown(5)
println("{i}") # 5, 4, 3, 2, 1
end
Common Generator forms (via default type parameters):
Generator[Y]— simple iterator (R=(),N=never)Generator[Y, R]— iterator with return value (N=never)Generator[Y, R, N]— full two-way communication
Generator completion:
When a generator function's body reaches the end, the last expression is implicitly returned — just like normal functions (see §2.9.1). When the last statement is a loop or other non-expression, the implicit return value is (), completing the generator with Done(()):
gen fn countdown(from: int) -> Generator[int]
mut i = from
while i > 0
yield i
i -= 1
end
# implicit: return ()
# caller receives Done(())
end
If a generator declares an explicit R other than (), the last expression must produce a value of type R — or the body must use an explicit return on all code paths. This follows the same implicit return rules as normal functions (§2.9.1).
Post-completion behavior:
Once a generator has returned Done(R), all subsequent calls to .next() return Done(R) with the same value. The generator body is not re-entered. On post-completion calls, passing Some(v) as the value argument panics — since the generator body is not re-entered, no yield expression exists to receive the value, so providing one is a programming error. Only None is valid on post-completion calls (for generators with N = never, this is enforced statically since only None can be passed). This is consistent with the Iterator[T] contract (stdlib/interfaces.md), which requires that once .next() returns None, all subsequent calls also return None — for generators with default R and N, Done(()) maps to None on every subsequent call.
3.4: For Loops
The for loop iterates over any type that conforms to the Iterable[T] interface. The loop calls iter() to obtain an Iterator[T], then repeatedly calls .next() on the iterator until it returns None.
Single-variable form:
for item in [1, 2, 3]
println("{item}")
end
Loop variables are immutable by default. Without the mut modifier, loop variable bindings (item, k, v) cannot be reassigned:
# Error: loop variable is immutable
for item in items
item += 1 # error — cannot mutate item
end
Mutable loop variables. Use for mut to make the loop variable mutable:
for mut item in items
item += 1 # ok — item is mutable
println("{item}")
end
The mut modifier makes the loop variable a separate mutable binding for each element. Reassigning the loop variable does not modify the collection. However, because Leaf uses reference semantics, calling mutating methods on the loop variable will affect the underlying object (which is shared with the collection).
When mut is used with a destructuring pattern, all extracted bindings are mutable, consistent with destructuring in variable bindings (§2.14):
for mut (key, value) in mutableMap
key = key + "_modified" # ok — key is mutable
value += 1 # ok — value is mutable
end
It is a compile-time error to use for mut when the iterated value is immutable. The source expression must be mutable. Mutability is determined by the root binding — if the expression is a named binding, it must be mut; if it is a field access chain like a.b.c, the root binding a must be mut (see semantics/core.md §2.2):
items = [1, 2, 3] # immutable binding
for mut item in items # error — items is immutable
item += 1
end
mut mutableItems = [1, 2, 3] # mutable binding
for mut item in mutableItems # ok
item += 1
end
container = Container { items: [1, 2, 3] }
for mut item in container.items # error — container is immutable, so container.items is too
item += 1
end
mut mutableContainer = Container { items: [1, 2, 3] }
for mut item in mutableContainer.items # ok — root binding is mut
item += 1
end
Temporary expressions are permitted with for mut. Expressions that are not rooted at a named binding — literals, function calls — produce fresh values with no aliasing concern, so for mut is allowed:
for mut item in [1, 2, 3] # ok — list literal is a fresh temporary
item += 1
end
for mut item in countdown(5) # ok — function call result is a fresh temporary
item += 1
end
To transform elements without mutation, use functional methods like .map():
doubled = items.map(fn(x) x * 2) |> toList
To modify a mutable collection in place, iterate over indices with .enumerate():
for (i, item) in items.enumerate()
items[i] = item + 1
end
Destructuring patterns in for loops.
A for loop binding can be any destructuring pattern — tuple, list, or map/struct — following the same rules as destructuring in variable bindings (see semantics/patterns.md §2.14). The most common form is tuple destructuring:
for (k, v) in expr
body
end
This requires expr to satisfy Iterable[Tuple[A, B]] for some types A and B. The loop calls iter() to get an Iterator[Tuple[A, B]], then destructures each tuple: k binds to .0 and v binds to .1.
Since Map[K, V] satisfies Iterable[Tuple[K, V]], maps work directly with tuple destructuring. List[T] satisfies Iterable[T] (not tuples), so tuple destructuring does not apply to plain lists. To iterate with indices, use items.enumerate(), which yields Tuple[uint, T] pairs lazily (see stdlib/interfaces.md for the full signature):
for (i, item) in items.enumerate()
println("{i}: {item}")
end
Either binding can be replaced with _ to discard that element:
for (_, v) in m # discard key, bind value
println("{v}")
end
for (k, v) in {a: 1, b: 2} # k: str, v: int
println("{k} = {v}")
end
Tuple destructuring generalizes to any arity. Any type satisfying Iterable[Tuple[A, B, ...]] can be destructured:
for (name, score, grade) in studentRecords
println("{name}: {score} ({grade})")
end
Other destructuring patterns also work in for loops. List and map/struct destructuring follow the same rules as in variable bindings:
# List destructuring — each element is a List[int] with known structure
for [x, y] in listOfPairs
println("{x}, {y}")
end
# Map/struct destructuring
for {name, age} in listOfPeople
println("{name} is {age}")
end
User-defined types that yield tuples support destructuring naturally. Any type satisfying Iterable[Tuple[A, B]] works:
struct Pairs implements Iterable[Tuple[str, int]]
data: List[Tuple[str, int]]
pub gen fn iter(self) -> Generator[Tuple[str, int]]
for pair in self.data
yield pair
end
end
end
for (name, score) in Pairs { data: [("alice", 100), ("bob", 85)] }
println("{name}: {score}")
end
Iterating a generator directly:
gen fn fibonacci(limit: int) -> Generator[int]
mut a = 0
mut b = 1
while a < limit
yield a
temp = a
a = b
b = temp + b
end
end
for n in fibonacci(100)
println("{n}")
end
The Iterable[T] interface:
Any type can be used in a for loop by implementing Iterable[T]:
interface Iterable[out T]
fn iter(self) -> Iterator[T]
end
The iter() method returns an Iterator[T]. Conforming types can implement this with a gen fn that returns Generator[T], since Generator[T, (), never] satisfies Iterator[T] structurally (see §3.3).
Structs declare conformance explicitly via implements (see structs/core.md §4.5) and the compiler verifies that method signatures match:
struct Range implements Iterable[int]
pub start: int
pub stop: int
pub fn new(start: int, stop: int)
return Self { start: start, stop: stop }
end
pub gen fn iter(self) -> Generator[int]
mut i = self.start
while i < self.stop
yield i
i += 1
end
end
end
for i in Range.new(0, 10)
println("{i}")
end
Generator[T, (), never] satisfies both Iterator[T] and Iterable[T]. This is compiler-provided conformance: the generator's .next(value: Option[never]) method is compatible with Iterator[T]'s .next() -> Option[T] because Option[never] can only ever be None (no Some(never) value is constructible), so the compiler always passes None and elides the argument. GeneratorResult[T, ()] is isomorphic to Option[T] — the compiler automatically synthesizes the translation: Yielded(value) becomes Some(value), and Done(()) becomes None. The iter() method returns self.
When the type checker sees for x in expr or for mut x in expr, it checks:
- If
exprsatisfiesIterable[T], calliter()and iterate.xhas typeT. - If
mutis present:exprmust be mutable. Ifexpris a named binding or a field access chain rooted at a named binding, the root binding must bemut(§2.2). Expressions not rooted at a named binding (literals, function calls) are always permitted as they produce fresh values. - Otherwise, type error.
When the type checker sees for [mut] <pattern> in expr where <pattern> is a destructuring pattern, it checks:
- If
exprsatisfiesIterable[T]for someT, calliter()and iterate. - If
mutis present, the same mutability rule applies: named bindings must be mutable; non-binding expressions (temporaries) are always permitted. - Each element of type
Tis destructured according to the pattern. Type checking follows the same rules as destructuring in variable bindings (seesemantics/patterns.md§2.14). For tuple patterns,Tmust beTuple[A, B, ...]with arity matching the pattern. For list patterns,Tmust beList[U]. For map/struct patterns,Tmust be a map or struct type with the named fields. - Otherwise, type error.
Internal iterator mutability. The for loop internally binds the iterator returned by iter() as a mutable value, since Iterator[T].next(mut self) requires a mutable receiver. This internal binding is not visible to user code and is independent of whether the loop variable itself is mut.
Generator[T, (), never], List[T], and Map[K, V] all satisfy Iterable, so they all work with the single-variable form. For tuple destructuring, Map[K, V] satisfies Iterable[Tuple[K, V]] directly. Any user-defined type that iterates tuples also works.
Structural modification during iteration. Adding or removing elements from a List, Map, or Set while a for loop is iterating over it is a runtime panic. This includes calls to methods that change the collection's size or key set (e.g., push, pop, remove, add, clear, map key insertion/deletion). Element-level mutation via bracket assignment (e.g., items[i] = x) is safe — it modifies an existing element without changing the collection's structure. For maps, bracket assignment on an existing key is safe (value update only). Bracket assignment on a new key inserts an entry, which is a structural modification and panics during iteration. Note that Map.set is always treated as a structural modification during iteration (even when updating an existing key) because the runtime does not perform a key lookup to distinguish inserts from updates — use bracket assignment m[key] = value to safely update existing map values during iteration.
3.5: Flow Control
if/else and match are expressions. They produce a value and can appear anywhere an expression is expected — on the right side of an assignment, as a function argument, inside a return statement, etc.
while and for are statements. They do not produce a value and cannot be used as expressions.
3.5.1: if/else Expressions
An if/else chain is an expression whose value is the value of the taken branch. All branches must produce the same type (or diverge with never):
x = if condition
42
else
0
end
# x: int
The inline body form (§2.0.2) allows compact single-line expressions:
x = if condition 42 else 0
sign = if n > 0 "positive" elseif n < 0 "negative" else "zero"
Dangling else. When an inline if body is itself an if expression, an else on the same line is resolved by the nearest unmatched if rule (see §2.0.2). To attach else to an outer if, wrap the inner if in parentheses or use the multi-statement form.
An if with no else branch. Since the else branch is absent, no value is produced when the condition is false. If the if body always diverges (via return, panic, break, or continue), the code after the if is only reachable when the condition was false. If the if body produces a value and there is no else, the expression cannot be assigned — the compiler reports an error because the non-taken path has no value:
# Valid — the if body diverges, so the function continues only when
# the condition is false:
fn requirePositive(n: int) -> int
if n <= 0
panic("must be positive") # returns never — exits the function
end
return n # only reached when n > 0
end
# Invalid — cannot assign because the else path has no value:
# x = if condition
# 42
# end # error: missing else branch
If one branch diverges (type never), the expression takes the type of the non-diverging branch, since never is assignable to every type.
When an if expression is used as a statement (its value is discarded), the else branch may be omitted freely:
if condition
println("yes")
end
# ok — value is not used
Leaf uses elseif (one word) for chained conditionals. else if (two words) is not valid syntax:
result = if x > 0
"positive"
elseif x < 0
"negative"
else
"zero"
end
# result: str
Multi-statement branches produce the value of their last expression:
description = if value > 100
log("large value detected")
"large"
else
"normal"
end
3.5.2: match Expressions
A match expression produces the value of the taken arm. All arms must produce the same type (or diverge with never):
message = match result
Ok(value) then "got: {value}"
Err(e) then "error: {e}"
end
# message: str
match must be exhaustive. For enums, every variant must be covered. For primitive types, a _ or catch-all binding is required (except bool, which is exhaustive with true + false). The compiler reports an error if coverage is incomplete:
x = match opt
Some(v) then v * 2
None then 0
end
# x: int
If one arm diverges (type never), the expression takes the type of the non-diverging arms, since never is assignable to every type.
Multi-statement arms use the block form: each arm body ends with end, and the outer end closes the match. The arm body produces the value of its last expression:
x = match result
Ok(value) then
validated = validate(value)
validated * 2
end
Err(e) then
log(e)
-1
end
end
matches in an if condition is non-exhaustive and follows the same rules as if — when used as an expression without an else clause, it must have type () or never. When an else clause is present, the matched and non-matched paths must have compatible types:
if result matches Ok(value)
println("got: {value}")
end
3.5.3: Loops
while loops:
mut i = 0
while i < 10
println("{i}")
i += 1
end
break and continue:
for item in items
if item matches Some(value)
if value == sentinel
break
end
process(value)
end
end
break and continue are valid inside while and for loops. Using them outside a loop is a compile error. break and continue do not cross function boundaries — they are valid only inside a loop within the same function body. A break or continue inside a closure or nested function defined within a loop body is a compile error, because the closure has its own function scope with no enclosing loop. Labeled break/continue are not supported.
3.5.4: return and Implicit Return
The last expression in a function body is implicitly its return value — no return keyword is needed:
fn add(a: int, b: int) -> int
a + b
end
fn classify(n: int) -> str
if n > 0
"positive"
else
"negative"
end
end
An explicit return is still valid and is the only way to exit early:
fn safeDivide(a: float, b: float) -> float
if b == 0.0
return 0.0 # early return
end
a / b # implicit return
end
The type of the last expression (or explicit return value) must match the function's return type. When the last statement is not an expression (e.g., a for or while loop), the return type is ().
See lang/semantics/functions.md §2.9.1 for the full implicit return specification.
? propagation:
The ? postfix operator provides early return for Result[T, E] and Option[T]. On Result, it unwraps Ok(v) or returns Err(e) from the enclosing function. On Option, it unwraps Some(v) or returns None.
fn process(path: str) -> Result[int, str]
content = readFile(path)? # early return on Err
value = parseInt(content)? # chain multiple fallible calls
return Ok(value * 2)
end
The enclosing function's return type must be compatible: Result[U, E] for Result propagation, Option[U] for Option propagation. See lang/semantics/values.md §2.12.3 for full details.