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:
- Structs provide concrete fields that anchor the type definition
- Enums provide variant structure with non-recursive variants as base cases
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:
- Type aliases — just rename types, don't provide concrete structure
- Function types (
fn(A) -> B) — don't provide values, only describe signatures Optionalone — provides a base case but not concrete type structure- Built-in collections alone (
List[T],Map[K, V]) — containers without concrete structure
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