2: Language Semantics — Functions


Contents


2.4: Closures and Nested Functions

Functions can be nested inside other functions. Inner functions capture variables from their enclosing scope:

fn makeAdder(n: int) -> fn(int) -> int
    fn add(x: int) -> int
        return x + n          # captures n from enclosing scope
    end
    return add
end

add5 = makeAdder(5)
println("{add5(3)}")          # 8

Closures capture by reference. Mutations to captured mut bindings are visible to the enclosing scope and vice versa:

fn counter() -> fn() -> int
    mut count = 0
    fn increment() -> int
        count += 1
        return count
    end
    return increment
end

next = counter()
println("{next()}")    # 1
println("{next()}")    # 2

Anonymous functions (lambdas) follow the same block body rules as named functions. Anonymous functions use fn only — gen fn lambdas are not supported (see types.md §3.3). The inline form is common when passing lambdas as arguments:

doubled = [1, 2, 3].map(fn(x: int) -> int x * 2) |> toList

Multi-statement lambdas use the end form:

processed = items.map(fn(x: int) -> int
    temp = x * 2
    temp + 1
end) |> toList

2.5: Function Types

Function types are written with the fn keyword:

type Predicate = fn(int) -> bool
type Transform = fn(str) -> str
type Consumer = fn(int) -> ()         # returns unit
type Supplier = fn() -> int
type Logger = fn(...str) -> ()        # variadic function type (see §2.16)

Parameter types are listed inside (...). The return type follows ->. If the function takes no parameters, the parentheses are empty: fn() -> int.

A variadic function type uses ...T for the final parameter type: fn(str, ...int) -> (). A fn(...T) -> R type is distinct from fn(List[T]) -> R — only a variadic function can be passed in its place. See §2.16 for details.

The unit type () represents "no meaningful value" and is the return type of functions that return nothing.

2.6: Variance

2.6.1: Subtyping in Leaf

Leaf has subtyping through the special type never and interface conformance. Assignability follows these rules:

2.6.2: Function Type Variance

Function types are contravariant in parameters and covariant in returns:

This follows standard type-theoretic rules: a function that accepts more types (wider parameter) and returns fewer types (narrower return) is safely substitutable.

2.6.3: Generic Type Variance

Generic type declarations — structs, enums, and interfaces — support declaration-site variance via in and out annotations on type parameters. Without an annotation, a type parameter is invariant (the safe default).

Type aliases also support variance annotations. The compiler checks that the declared variance is consistent with how the type parameter is used in the underlying type. See structs/data-types.md §4.9 for details.

Functions do not support variance annotations on their type parameters. Function type parameters are universally quantified at each call site, not subtyped — fn foo[out T](x: T) is a compile error.

# Covariant struct — only produces T values
struct Box[out T]
    value: T

    pub fn get(self) -> T
        return self.value
    end
end

# Covariant interface — only produces T values
interface Iterable[out T]
    fn iter(self) -> Iterator[T]
end

# Contravariant interface — only consumes T values
interface Comparable[in T]
    fn compare(self, other: T) -> Ordering
end

# Invariant (default) — both reads and writes T
interface Storage[T]
    fn read(self, id: int) -> T
    fn write(mut self, id: int, value: T)
end

# Covariant enum — only produces T values
enum Response[out T]
    Success(value: T)
    Error(message: str)
end

The never bottom-type exception: For any generic type G[T1, ..., Tn], if every type parameter is never, then G[never, ..., never] is assignable to G[U1, ..., Un] for any types U1, ..., Un. This is sound because never is uninhabitable — no runtime value has type never, so a G[never, ..., never] is guaranteed to be empty. You cannot extract a value from it (any extraction produces never, which is assignable to every type).

This rule enables empty collection literals ([], {}) to be assignable to any List[T] or Map[K, V] without breaking invariance for all other cases. See §2.7 for details.

2.6.4: Variance Checking Rules

The compiler verifies that out parameters appear only in output (return) positions and in parameters appear only in input (parameter) positions. These rules apply uniformly to structs, enums, and interfaces. Violations are compile errors.

Constructor exemption. Struct literal construction and enum variant construction are exempt from variance position checking. A struct field or enum variant field of type T is writable during construction even when T is declared out. After construction, the variance rules apply normally — an out T field can only be read, and an in T field can only be written.

This means struct Box[out T] with a field value: T is legal: the field is set in the struct literal (Box { value: x }) and read via methods like fn get(self) -> T. The covariant annotation is safe because once constructed, the only way to interact with the value is through the type's methods, which are variance-checked normally.

Similarly, enum Response[out T] with variant Success(value: T) is legal: the variant constructor Response.Success(x) sets value, and pattern matching reads it — both are safe for covariance.

Tuple covariance. All tuple element types are covariant: Tuple[out T1, out T2, ..., out Tn]. Tuples are immutable after construction — elements are readable but not writable — so covariance is sound. This means Tuple[Cat, Dog] is a subtype of Tuple[Animal, Animal] when Cat and Dog are subtypes of Animal.

Method position rules. When checking variance positions of a type parameter T within a struct or interface's methods, the self parameter is excluded from the analysis (it is the type being defined). Regular method parameters place T in input (contravariant) position. Method return types place T in output (covariant) position. mut self methods that write to an out T field through self are a compile error — mutation after construction violates the covariance guarantee.

2.6.5: Summary

Kind Parameters Returns Default
Function types Contravariant Covariant (inherent)
Generic structs in (contravariant) out (covariant) Invariant
Generic enums in (contravariant) out (covariant) Invariant
Interfaces in (contravariant) out (covariant) Invariant

All generic types share the same variance model. The bottom-type exception also applies uniformly: G[never, ..., never] is assignable to any G[T1, ..., Tn] (see §2.6.3).

2.7: Type Inference

Leaf uses local type inference. The type of a variable is inferred from its initializer when no annotation is provided:

x = 42                  # inferred as int
name = "hello"          # inferred as str
items = [1, 2, 3]       # inferred as List[int]

Type arguments on generic functions are inferred from the call site:

fn identity[T](value: T) -> T
    return value
end

x = identity(42)        # T inferred as int

Explicit type arguments can be provided when inference is insufficient:

x = identity[int](42)

Bracket disambiguation rule. The syntax f[x] is ambiguous between bracket indexing and explicit type arguments. The grammar does not distinguish these forms — the parser produces an ambiguous node. During name resolution, the compiler determines the interpretation based on what the identifier resolves to:

Normal scoping rules (§2.1) apply. If a local variable shadows a type name, bracket access on that name is indexing:

struct Box[T]
    pub value: T
end

fn example()
    Box = [1, 2, 3]     # shadows the struct name
    x = Box[0]           # bracket indexing — Box is a List[int]
end

A bare type instantiation like Foo[int] in expression context is not a value and is a compile error unless followed by { ... } (struct literal), . (variant or method access), or ( (call). Syntax-only tools (formatters, highlighters) cannot distinguish bracket indexing from type arguments without name resolution — this is an intentional tradeoff for syntactic simplicity.

Empty collection literal inference:

An empty list literal [] has type List[never]. An empty map literal {} has type Map[never, never]. By the bottom-type exception (§2.6.3), List[never] is assignable to any List[T] and Map[never, never] is assignable to any Map[K, V]. This is not covariance — generic structs remain invariant. It is a special rule that applies only when every type parameter is never, which guarantees the collection is empty and therefore safe to treat as any instantiation.

Empty collections are directly assignable to any compatible collection type (this follows from the bottom-type exception, not from variance):

fn makeList() -> List[int]
    return []           # ok — List[never] assignable to List[int]
end

fn collect(items: List[str])
    # ...
end
collect([])             # ok — List[never] assignable to List[str]

To create an empty collection and populate it later, use an explicit type annotation:

mut items: List[int] = []   # explicit — type is List[int] from the start
items.push(42)              # ok — items is List[int]

mut m: Map[str, int] = {}   # explicit
m["a"] = 1                  # ok — m is Map[str, int]

Without an annotation, the inferred List[never] type cannot be mutated:

mut items = []              # List[never]
items.push(42)              # ERROR — cannot push int to List[never]

Inference does not cross function boundaries. Function parameters always require type annotations. The exception is lambda parameters, which may be inferred from the expected function type when the lambda is used in a context where the type is known.

Lambda parameter type inference:

Lambda parameters can omit type annotations when the compiler has enough contextual information to infer them unambiguously. This occurs when:

  1. The lambda is passed as an argument to a function with a known signature
  2. The lambda is assigned to a variable with an explicit function type
  3. The expected function type is otherwise known from context
# Contextual inference from List[int].map signature
doubled = [1, 2, 3].map(fn(x) x * 2) |> toList  # x inferred as int

# Contextual inference from variable type
adder: fn(int) -> int = fn(x) x + 10  # x inferred as int

# Contextual inference from function parameter
fn apply(f: fn(int) -> int, value: int) -> int
    return f(value)
end
result = apply(fn(x) x * 2, 5)  # x inferred as int

When the context does not provide enough information, explicit type annotations are required:

# Ambiguous context — requires explicit types
f = fn(x: int) x * 2

# Generic function — may require explicit types
fn identity[T](value: T) -> T
    return value
end
g = identity(fn(x: int) x + 1)  # explicit type required

Return types may be omitted when the return type can be inferred from the function body (see §2.9).

2.8: Variable Type Annotations

Variables can have explicit type annotations:

x: int = 42
name: str = "hello"
items: List[int] = [1, 2, 3]
result: Option[int] = Some(42)

Annotations are required when the type cannot be inferred or when the programmer wants to be explicit.

2.9: Return Types

Function return types can be annotated explicitly or inferred:

# Explicit return type
fn add(a: int, b: int) -> int
    return a + b
end

# Inferred return type (from the last expression)
fn add(a: int, b: int)
    a + b
end

Both forms are equivalent — the last expression in a function body is implicitly its return value (see §2.9.1). An explicit return is also valid and is the only way to exit a function early.

Return type annotations are required for:

The -> () annotation may be omitted from any function since () is the default return type. For recursive functions, the -> () annotation is still optional since () is always the default. The requirement above applies to non-unit return types where inference would need to examine the recursive call.

The unit type () is the default return type when no value is returned:

fn greet(name: str)
    println("Hello {name}")
end
# Equivalent to: fn greet(name: str) -> ()

2.9.1: Implicit Return

The last expression in a function body is its return value. An explicit return keyword is not required:

fn add(a: int, b: int) -> int
    a + b
end

fn greet(name: str) -> str
    "Hello, {name}"
end

This is equivalent to writing return a + b or return "Hello, {name}". The type of the last expression must match the function's return type (whether annotated or inferred).

return is still valid and is the only way to exit a function early:

fn safeDivide(a: float, b: float) -> float
    if b == 0.0
        return 0.0          # early return
    end
    a / b                    # implicit return
end

if/else and match as the last expression work naturally, since they are expressions that produce values (see §3.5):

fn classify(n: int) -> str
    if n > 0
        "positive"
    elseif n < 0
        "negative"
    else
        "zero"
    end
end

fn describe(opt: int?) -> str
    match opt
        Some(v) then "value: {v}"
        None then "empty"
    end
end

When the last statement is not an expression (e.g., a for loop or while loop), or when the last expression has type () (e.g., a call to a function returning unit), the function's return type is ():

fn printAll(items: List[int])
    for item in items
        println("{item}")
    end
end
# return type is () — the last statement is a for loop (not an expression)

Implicit return applies to all function forms: top-level functions, struct methods, closures, and lambdas:

doubled = items.map(fn(x: int) -> int x * 2) |> toList
tripled = items.map(fn(x: int) -> int
    x * 3
end) |> toList
Link copied to clipboard!