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:

# 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:

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:

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:

3.3: Generators

A generator function is declared with the gen fn keyword and a return type of Generator[Y, R, N]:

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 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:

Two-way communication (Nnever):

For generators with Nnever, .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]:

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 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:

  1. If expr satisfies Iterable[T], call iter() and iterate. x has type T.
  2. If mut is present: expr must be mutable. If expr is a named binding or a field access chain rooted at a named binding, the root binding must be mut (§2.2). Expressions not rooted at a named binding (literals, function calls) are always permitted as they produce fresh values.
  3. Otherwise, type error.

When the type checker sees for [mut] <pattern> in expr where <pattern> is a destructuring pattern, it checks:

  1. If expr satisfies Iterable[T] for some T, call iter() and iterate.
  2. If mut is present, the same mutability rule applies: named bindings must be mutable; non-binding expressions (temporaries) are always permitted.
  3. Each element of type T is destructured according to the pattern. Type checking follows the same rules as destructuring in variable bindings (see semantics/patterns.md §2.14). For tuple patterns, T must be Tuple[A, B, ...] with arity matching the pattern. For list patterns, T must be List[U]. For map/struct patterns, T must be a map or struct type with the named fields.
  4. 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.

Link copied to clipboard!