4: Structs, Interfaces, Enums, and Collections — Data Types


Contents


4.6: Enums

4.6.1: Simple Enums

Enums are tagged types. By convention, enum names and variant names use PascalCase. An enum must declare at least one variant. Zero-variant enums are a compile error. Use the never type for uninhabited types.

enum Direction
    North
    South
    East
    West
end

Enum variants are referenced using . syntax:

d = Direction.North

if d == Direction.South
    println("Going south")
end

To use variants without qualification, import them with use Enum.*:

use Direction.*

d = North
if d == South
    println("Going south")
end

See lang/modules.md §6.3 for full details on enum variant imports.

Enum equality (== and !=) is built-in behavior and does not require implementing Eq.

Enum string interpolation is also built-in. Enum values can be used in string interpolation without implementing ToString. The output format is unspecified, unstable across compiler releases, and intended as a developer-facing diagnostic aid only.

To test a specific variant of a simple enum, use ==. For enums with associated data, use pattern matching (see §4.6.2).

Enums do not have methods. When richer behavior is needed, use a struct.

Interface conformance. Enums satisfy PartialEq[T] and Eq[T] as compiler-provided conformance. For enums with associated data, conformance requires all associated value types to satisfy PartialEq. Enums also satisfy Hash when all associated value types satisfy Hash (unconditional for simple enums). See stdlib/interfaces.md for details.

4.6.2: Enums with Associated Data

Enum variants can carry data:

enum Shape
    Circle(float)
    Rect(float, float)
    Point
end

s = Shape.Circle(5.0)

Enum variants with associated data use constructor syntax: Shape.Circle(5.0) constructs the Circle variant. These are not methods — there is no self parameter.

Associated data is accessed via pattern matching, which destructures the variant and binds the associated values to named variables:

fn area(shape: Shape) -> float
    match shape
        Shape.Circle(r) then return 3.14159 * r.pow(2.0)
        Shape.Rect(w, h) then return w * h
        Shape.Point then return 0.0
    end
end

The matches operator handles a single variant without requiring exhaustiveness:

if shape matches Shape.Circle(r)
    println("radius: {r}")
end

Enum variants can declare named fields:

enum WebEvent
    Click(x: int, y: int)
    KeyPress(key: str)
    Scroll(delta: float)
    Resize(width: int, height: int)
end

Construction and destructuring are always positional. Construction of variants with named fields is positional — field names serve as documentation at the declaration site but are not used in constructor syntax. Whether fields are named or unnamed, the binding names in patterns are chosen by the programmer. Fields are bound by position, not by name:

event = WebEvent.Click(100, 200)
match event
    WebEvent.Click(a, b) then println("clicked at {a}, {b}")    # valid
    WebEvent.KeyPress(k) then println("key: {k}")                # valid
    WebEvent.Scroll(d) then println("scroll: {d}")               # valid
    WebEvent.Resize(w, h) then println("resize: {w}x{h}")       # valid
end

Named fields serve as documentation at the declaration site and in constructor calls, but have no semantic effect on pattern matching:

match shape
    Shape.Circle(radius) then println("r = {radius}")
    Shape.Rect(w, h) then println("{w} x {h}")
    Shape.Point then println("origin")
end

Nested patterns. Each position in a pattern can itself be a pattern, allowing destructuring through multiple layers:

fn describe(result: Result[Shape, str])
    match result
        Ok(Shape.Circle(r)) then println("circle with radius {r}")
        Ok(Shape.Rect(w, h)) then println("rect {w}x{h}")
        Ok(Shape.Point) then println("point")
        Err(e) then println("error: {e}")
    end
end

Pattern matching on enum variants is the only way to access associated data. There is no field access syntax (.0, .value, etc.) on enum values. This ensures all variant handling is explicit and exhaustive.

Equality for enums with associated data compares both the variant tag and the associated values. The associated value types must implement PartialEq for the enum to support ==.

4.6.3: Generic Enums

User-defined enums can have type parameters, just like structs. Type parameters follow the enum name in square brackets and may include constraints and default types (see §4.4 for default type parameter syntax):

enum Response[T, E = str]
    Success(T)
    Failure(E)
end

r = Response[int].Success(42)       # E defaults to str
r2 = Response[int, int].Failure(404)

Generic enums support declaration-site variance annotations (see lang/semantics/functions.md §2.6.3):

enum Container[out T]
    Value(T)
    Empty
end

With out T, a Container[Cat] is assignable to Container[Animal] when Cat is a subtype of Animal. The compiler enforces that T appears only in output positions across all variants.

4.6.4: Built-in Enums

Leaf provides several built-in enum types:

Option[T] — represents an optional value:

enum Option[out T]
    Some(T)
    None
end

The ? suffix is sugar: int? means Option[int].

fn find(items: List[int], target: int) -> int?
    for item in items
        if item == target
            return Some(item)
        end
    end
    return None
end

result = find([1, 2, 3], 2)
if result matches Some(value)
    println("found: {value}")
end

Result[T, E] — represents success or failure:

enum Result[out T, out E]
    Ok(T)
    Err(E)
end

Result replaces exceptions. Functions that can fail return Result:

fn parseInt(s: str) -> Result[int, str]
    # ...implementation...
end

result = parseInt("42")
match result
    Ok(value) then println("parsed: {value}")
    Err(e) then println("error: {e}")
end

The ? operator provides ergonomic propagation — it unwraps Ok(v) or immediately returns Err(e) from the enclosing function:

fn doubleParsed(s: str) -> Result[int, str]
    value = parseInt(s)?           # unwrap or early-return Err
    return Ok(value * 2)
end

See lang/semantics/values.md §2.12.3 for full details on ?.

Ordering — represents a comparison result:

enum Ordering
    Less
    Equal
    Greater
end

Returned by Comparable[T].compare. See stdlib/types.md for interface conformance details.

GeneratorResult[Y, R] — represents a generator step:

enum GeneratorResult[out Y, out R]
    Yielded(Y)
    Done(R)
end

Used by the Generator type's .next() method.

4.7: Tuples

Tuples are fixed-size, heterogeneous, ordered collections. A tuple holds between 2 and 10 elements, each of which may have a different type.

Tuple types:

The tuple type is written Tuple[T1, T2, ...] with 2 to 10 type parameters:

Tuple[int, str]                     # 2-tuple
Tuple[int, str, bool]               # 3-tuple
Tuple[int, int, int, int]           # 4-tuple

A tuple with fewer than 2 or more than 10 elements is a compile error. The 10-element limit is an arbitrary implementation choice to keep tuple implementation manageable. Larger fixed-arity collections should use structs; variable-arity collections should use List[T].

Tuple literals:

Tuples are constructed with parenthesized, comma-separated expressions:

pair = (1, "hello")                  # Tuple[int, str]
triple = (true, 3.14, "yes")        # Tuple[bool, float, str]
nested = ((1, 2), (3, 4))           # Tuple[Tuple[int, int], Tuple[int, int]]

A single-element parenthesized expression is not a tuple — it is a grouping expression: (42) is just 42. To create a tuple, at least two elements are required.

Unit type () is not a tuple. The unit type () (representing "no meaningful value") is distinct from the Tuple[...] family. Zero-element tuples are not legal syntax — Tuple[] is a compile error. The unit type has special semantics as the return type of functions with no return value and as the value of statements that produce no result. () in both type position and value position is always the unit type, never a tuple. Zero-element tuples do not exist in Leaf's type system.

Element access:

Tuple elements are accessed with .0, .1, .2, etc.:

pair = (1, "hello")
pair.0          # 1 — type int
pair.1          # "hello" — type str

The index must be an integer literal within bounds — pair.2 on a 2-tuple is a compile error. Dynamic indexing (e.g., pair[i]) is not supported.

Destructuring:

Tuples support destructuring on the left side of an assignment:

(x, y) = (10, 20)
(name, age, active) = ("Alice", 30, true)

Tuple destructuring is also available in function parameters:

fn distance((x, y): Tuple[float, float]) -> float
    return (x.pow(2.0) + y.pow(2.0)).pow(0.5)
end

Spread/rest patterns are not supported for tuples. Unlike list destructuring, which supports [head, ...tail] (§2.14.2), there is no (first, ...rest) syntax for tuples.

Tuples are immutable. There is no way to modify a tuple element in place. To "update" a tuple, destructure it and construct a new one.

Interface conformance:

Tuples conditionally satisfy Eq, Hash, and ToString:

Interface Condition
PartialEq[Tuple[...]] when all element types satisfy PartialEq
Eq[Tuple[...]] when all element types satisfy Eq
Hash when all element types satisfy Hash
ToString when all element types satisfy ToString

Equality compares elements pairwise — two tuples are equal when they have the same arity and all corresponding elements are equal.

Tuples do not satisfy Comparable — there is no built-in ordering for tuples.

4.8: Collections

List[T], Map[K, V], Set[T], and Buffer are built into the language as part of the prelude. They are always available and have a fixed API. The complete method listings are in stdlib/types.md. The sections below describe the most common usage patterns for the generic collections.

4.8.1: Lists

Lists are ordered, growable sequences:

items = [1, 2, 3, 4, 5]        # List[int]
names = ["alice", "bob"]        # List[str]

Bracket access:

first = items[0]                # int — returns the element
items[0] = 10                   # sets the element (requires mut)

Out-of-bounds access is a runtime error.

Common methods:

items.length() -> uint
items.push(value: T)            # requires mut
items.pop() -> Option[T]        # requires mut
items.contains(value: T) -> bool    # requires T: PartialEq[T]

Functional methods like map, filter, flatMap, reduce, find, any, all, each, join, and enumerate are inherited from the Iterable[T] interface (see stdlib/interfaces.md). The map, filter, flatMap, and enumerate methods return lazy Iterator values; use the toList(), toMap(), or toSet() free functions (in stdlib/functions.md) to materialize.

Lists are iterable — they can be used in for loops:

for item in items
    println("{item}")
end

for (i, item) in items.enumerate()
    println("{i}: {item}")
end

4.8.2: Maps

Maps are key-value collections. The key type K must satisfy both Eq[K] and Hash — see stdlib/types.md for details.

m = {a: 1, b: 2, c: 3}        # Map[str, int]

Bracket access:

val = m["a"]                   # int — returns the value, panics if key missing
m["c"] = 3                     # sets the value (requires mut)

Map bracket access returns V directly. If the key is not present, it is a runtime error (panics). Use .get(key) for safe access returning Option[V].

Common methods:

m.length() -> uint
m.has(key: K) -> bool
m.delete(key: K) -> Option[V]    # requires mut
m.keys() -> List[K]
m.values() -> List[V]

Maps are iterable, yielding key-value pairs as Tuple[K, V]:

Iteration order is unspecified. Programs must not depend on any particular order. Implementations are free to use insertion order, arbitrary order, or any other strategy.

for (k, v) in m
    println("{k} = {v}")
end

for (_, v) in m                 # discard key, bind value only
    println("{v}")
end

4.8.3: Sets

Set[T] is an unordered collection of unique values. The element type T must satisfy both Eq[T] and Hash.

There is no set literal syntax. Sets are created using the Set.from() static constructor or by converting a list with toSet:

s = Set.from([1, 2, 3])           # Set[int]
s2 = [1, 2, 2, 3] |> toSet        # Set[int] — duplicates discarded

Sets are iterable — they can be used in for loops. Iteration order is unspecified.

See stdlib/types.md for the complete Set[T] API.

4.8.4: Collection Literals

The literal forms [...] and {...} are special syntactic forms:

items = [1, 2, 3]              # List[int]
m = {a: 1, b: 2}              # Map[str, int]

{...} without a type name is always a Map literal. Struct literals require a type name prefix (e.g., Point { x: 1, y: 2 }). A bare {a: 1, b: 2} is always a map.

Map key types: Bare identifiers always produce string keys. Computed key syntax ({[expr]: value}) allows any key type:

{[42]: "hello"}                 # Map[int, str]
{["a"]: 1, ["b"]: 2}           # Map[str, int]

Spread in list literals:

The ... operator can inline another list into a list literal:

base = [1, 2, 3]
extended = [...base, 4, 5]          # [1, 2, 3, 4, 5]
combined = [...base, ...base]       # [1, 2, 3, 1, 2, 3]
prefixed = [0, ...base]             # [0, 1, 2, 3]

The spread source must be a List[T]. All elements (spread and non-spread) must be compatible with the same element type T. Multiple spreads and individual elements can be freely interleaved:

a = [1, 2]
b = [3, 4]
result = [0, ...a, 99, ...b, 100]   # [0, 1, 2, 99, 3, 4, 100]

Spread in list literals creates a new list (shallow copy) — it does not modify the source. However, the elements are shared references, so modifying nested collections affects both the original and the result. Mutability constraints apply: spreading a non-mutable list into a mutable binding is a compile error (see lang/semantics/patterns.md §2.14.7).

Spread in map literals:

The ... operator can inline another map into a map literal:

defaults = {host: "localhost", port: "8080", debug: "false"}
overrides = {...defaults, port: "3000", debug: "true"}
# {host: "localhost", port: "3000", debug: "true"}

When a spread map and an explicit entry have the same key, later entries win (left-to-right evaluation). Multiple spreads can be combined:

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]. All entries (spread and explicit) must be compatible with the same K and V types. Spread in map literals creates a new map (shallow copy) — it does not modify the source. However, the values are shared references, so modifying nested collections affects both the original and the result. Mutability constraints apply: spreading a non-mutable map into a mutable binding is a compile error (see lang/semantics/patterns.md §2.14.7).

Only lists and maps can be spread. Spreading a struct, tuple, or other type into a collection literal is not supported. To merge struct fields into a map, destructure the struct first and build the map explicitly.

See lang/semantics/patterns.md §2.14.7 for the full specification of spread in collection literals.

Empty collection literals:

An empty list [] has type List[never] and an empty map {} has type Map[never, never]. By the bottom-type exception (lang/semantics/functions.md §2.6.3), these are assignable to any List[T] or Map[K, V]. This follows from the bottom-type exception, not from variance.

Empty collections can be returned directly where a typed collection is expected:

fn emptyNames() -> List[str]
    return []           # List[never] assignable to List[str]
end

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

mut items: List[int] = []   # explicit type annotation required
items.push(42)              # ok

mut m: Map[str, int] = {}   # explicit type annotation required
m["key"] = 1                # ok

See lang/semantics/functions.md §2.7 for the full inference rules.

4.9: Type Aliases

Type aliases give a name to an existing type:

type NumberList = List[int]
type MaybeStr = str?
type Scores = Map[str, int]
type Callback[T] = fn(T) -> ()

Type aliases are top-level declarations only. They cannot be declared inside struct bodies, function bodies, or any other nested scope. They must appear at the module's top level, alongside structs, enums, interfaces, and functions.

Type parameter constraints. Generic type alias parameters support the same constraint syntax as other generic declarations (see §4.4) — both inline constraints and where clauses:

type KeyMap[K: Eq[K] & Hash] = Map[K, str]
type Lookup[K, V] where K: Eq[K] & Hash = Map[K, V]

When fewer constraints are needed, they can be omitted:

type Callback[T] = fn(T) -> ()          # no constraints on T

Constraints on type alias parameters are checked at each instantiation site. If the supplied type argument does not satisfy the constraint, it is a compile error at the instantiation — not at the alias definition:

KeyMap[str]          # ok — str satisfies Eq[str] & Hash
KeyMap[List[int]]    # error — List[int] does not satisfy Hash

Variance annotations. Generic type alias parameters support variance annotations (in/out), just like structs, enums, and interfaces (see semantics/functions.md §2.6.3). The compiler checks that the declared variance is consistent with how the parameter is used in the underlying type:

type Producer[out T] = Box[T]              # ok — Box[out T] is covariant
type Consumer[in T] = fn(T) -> ()          # ok — T is in input position
type Transform[in T, out U] = fn(T) -> U   # ok — T input, U output
type Broken[out T] = fn(T) -> ()           # error — T is in input position, not output

Variance annotations on type aliases are optional. When omitted, the type parameter is invariant (the default). Since aliases are transparent, the underlying type's variance applies regardless of annotation — but explicit annotations let the compiler catch inconsistencies at the alias definition site.

Visibility: Type aliases support visibility modifiers:

type Internal = Map[str, int]        # private (default)
pkg type Config = Map[str, str]      # package-public
pub type UserId = int                # public

The same visibility rules apply as for other top-level declarations (see semantics/core.md §2.3): unmarked aliases are module-private, pkg makes them visible within the package, and pub exports them to consumers.

Re-exports: Type aliases can be re-exported using pub use and pkg use, following the same rules as other top-level symbols (see modules.md §6.3.4):

# In module A:
pub type UserId = int

# In module B (lib.leaf):
pub use moduleA.UserId        # re-export as public

Structural (transparent) semantics: Type aliases are structural, not nominal. An alias is completely transparent to the type checker — it is simply a shorthand name for the underlying type. Wherever the alias appears, the compiler treats it as if the original type were written directly.

type UserId = int
type RequestId = int

fn process(id: UserId)
    # ...
end

requestId: RequestId = 42
process(requestId)              # ok — both are just int

There is no type safety barrier between UserId and RequestId — they are both int. If nominal typing is desired (distinct types that cannot be confused), use structs with single fields (wrapper types):

struct UserId
    pub value: int
end

struct RequestId
    pub value: int
end

fn process(id: UserId)
    # ...
end

requestId = RequestId { value: 42 }
process(requestId)              # error — RequestId is not UserId

Recursive type aliases: Recursive and mutually recursive type aliases are permitted, as long as the type can be instantiated. The compiler rejects type definitions that create unsatisfyable infinite expansions.

Valid patterns — recursion goes through a struct or enum definition, which provide concrete type structure and base cases that stop the recursion:

# Valid — recursion through a struct; Option provides the base case (None)
type Tree[T] = TreeNode[T]?

struct TreeNode[T]
    pub value: T
    pub left: Tree[T]     # can be None
    pub right: Tree[T]    # can be None
end

# Valid — enum provides base cases (Null, Bool, Number, String variants)
type JsonValue = Json

enum Json
    Null                                  # base case
    Bool(value: bool)                     # base case
    Number(value: float)                  # base case
    String(value: str)                    # base case
    Array(items: List[JsonValue])         # recursive
    Object(fields: Map[str, JsonValue])   # recursive
end

Invalid patterns — these create unsatisfiable types that cannot be instantiated:

# Invalid — direct self-reference through a type alias
type Foo = Foo
# Error: unsatisfiable type definition (infinite expansion)

# Invalid — mutual recursion through aliases only
type A = B
type B = A
# Error: unsatisfiable type definition (infinite expansion)

# Invalid — mutual recursion through Option but no struct or enum
type A = Option[B]
type B = Option[A]
# Error: Option provides a base case (None) but no concrete type structure.
# Neither alias resolves to a struct or enum — the types expand infinitely.

# Invalid — recursion through a container without a base case
type A = List[A]
# Error: unsatisfiable type definition (infinite expansion)
# Expands to List[List[List[...]]] with no way to construct a value

Rule: Recursive type aliases must pass through a struct or enum definition. These types provide the concrete type structure needed to resolve the recursion:

The Option type (via ?) provides a base case for constructability (the None constructor terminates the recursion at runtime) but does not substitute for concrete type structure. In type Tree[T] = TreeNode[T]?, the recursion is valid because TreeNode is a struct — Option merely makes the recursion terminable. Without the struct, Option wrapping another alias still creates infinite type expansion.

Disallowed as sole recursion points:

Built-in collections and Option CAN appear in recursive definitions, but the recursion must go through a struct or enum. In the Json enum example above, List[JsonValue] is valid because the enum provides the concrete structure. In the Tree example, Option (via ?) is valid because TreeNode is a struct.

Recursion through generic type parameters. A recursive reference that appears as a type argument to a struct or enum counts as "going through" that type, provided the type parameter is used as a field or variant data type. The enclosing type provides the necessary indirection and base cases:

# Valid — recursion through a struct's type parameter
struct Node[T]
    pub value: int
    pub child: T?
end

type Tree = Node[Tree]
# Node provides indirection; the Option (via T?) provides the base case (None)

# Valid — recursion through an enum's type parameter
enum Wrapper[T]
    Empty
    Holding(value: T)
end

type Recursive = Wrapper[Recursive]
# Wrapper.Empty provides the base case

# Invalid — recursion through a bare type alias parameter
type Alias[T] = T
type Bad = Alias[Bad]
# Error: Alias is a type alias, not a struct/enum — no indirection
Link copied to clipboard!