4: Structs, Interfaces, Enums, and Collections — Core


Contents


4.1: Structs

Structs define both data and behavior. By convention, struct names use PascalCase. Fields, methods, and functions are declared together in the struct body. Fields must come before methods. Method overloading is not supported — each method name may have exactly one signature per struct. Leaf has no inheritance, so there is no method overriding. Field and method names must not collide — a struct may not declare both a field and a method (including gen fn) with the same name; this is a compile error. This rule also applies when the field has a function type: a struct with pub x: fn() -> str (a function-typed field) and pub fn x(self) -> str (a method) is a compile error, because foo.x() would be ambiguous.

struct Person
    pub name: str
    age: int

    pub fn new(name: str, age: int)
        return Self { name: name, age: age }
    end

    pub fn greet(self) -> str
        return "Hi, I'm {self.name}"
    end

    pub fn birthday(mut self)
        self.age += 1
    end

    fn validate(self) -> bool
        return self.age > 0
    end
end

Struct destructuring: Struct values can be destructured using {...} syntax on the left side of an assignment, extracting fields into local bindings. Only fields visible to the calling code (respecting pub, pkg, and private visibility) can be destructured. Private fields are accessible anywhere within the defining module. See semantics/patterns.md §2.14.1 for full syntax and rules.

4.2: The Self Keyword

Self is available inside struct bodies — in constructors, instance methods, and static methods — where it refers to the enclosing struct type.

Self is not available in interface method signatures. However, Self may appear in where Self: ... supertrait constraints on an interface declaration, where it refers to the implementing type (see §4.5.1):

interface Eq[in T] where Self: PartialEq[T]
end

Shorthand field initialization:

pub fn new(x: int, y: int)
    return Self { x, y }       # equivalent to Self { x: x, y: y }
end

4.3: Constructors

Constructors are not required. The fundamental way to create a struct instance is the struct literalTypeName { field: value, ... } or Self { field: value, ... }. Every field must be set explicitly in every struct literal — there are no default field values. A declaration like pub x: int = 0 is not valid syntax. This is intentional: every construction site makes the full shape of the data visible, and factory methods (like new or named constructors) provide the idiomatic way to encapsulate common defaults.

Whether code can use a struct literal depends on field visibility:

struct Color
    r: int      # private
    g: int      # private
    b: int      # private

    pub fn new(r: int, g: int, b: int)
        return Self { r, g, b }
    end

    pub fn red() -> Self
        return Self.new(255, 0, 0)
    end
end

c = Color.new(1, 2, 3)             # ok
# c = Color { r: 1, g: 2, b: 3 }   # ok within same module, ERROR from other modules

Empty structs (zero fields) are valid. They are useful as marker types, sentinel values, or unit-like types:

struct Marker end

m = Marker {}                       # empty braces required

The new method name is reserved for constructors. Constructors do not take self and do not declare a return type — they implicitly return Self. The implicit -> Self return type is visible to the type checker and appears in error messages as if explicitly written. A struct may have at most one new declaration.

4.4: Generics

Type parameters are declared using [T] syntax:

struct Pair[A, B]
    first: A
    second: B

    pub fn new(first: A, second: B)
        return Self { first: first, second: second }
    end
end

p = Pair.new(42, "hello")     # Pair[int, str]

Generic functions:

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

fn swap[A, B](pair: Pair[A, B]) -> Pair[B, A]
    return Pair.new(pair.second, pair.first)
end

Type parameter constraints restrict which types may be substituted. Constraints use interface names after a colon:

fn max[T: Comparable[T]](a: T, b: T) -> T
    if a > b
        return a
    end
    return b
end

Multiple constraints use &:

fn printSorted[T: ToString & Comparable[T]](items: List[T])
    # ...
end

where clauses on functions. When inline constraints become unwieldy — especially with multiple type parameters or complex bounds — a where clause can follow the parameter list (or return type):

fn merge[T, U](a: List[T], b: List[U]) -> List[str] where T: ToString, U: ToString
    mut result: List[str] = []
    for item in a
        result.push(item.toString())
    end
    for item in b
        result.push(item.toString())
    end
    return result
end

where clauses and inline constraints may be mixed freely:

fn zipSorted[T: Comparable[T], U](a: List[T], b: List[U]) -> List[Tuple[T, U]] where U: Comparable[U]
    # ...
end

The where clause syntax is the same as on implements clauses (§4.5) and interface definitions (§4.5.1): a comma-separated list of TypeParam: Constraint bounds, where multiple constraints on a single parameter use &.

Default type parameters. Type parameters may declare default types using = Type syntax. Non-defaulted parameters must precede all defaulted parameters. A default may reference an earlier type parameter from the same declaration:

struct Container[T, E = str]
    value: Result[T, E]
end

Container[int]              # Container[int, str]
Container[int, MyError]     # explicit override

struct Graph[N, E, W = int]
    # W defaults to int
end

# A default may reference an earlier parameter
struct Wrapper[T, L = List[T]]
    items: L
end

Wrapper[int]                # Wrapper[int, List[int]]

Default type parameters work on any declaration that accepts type parameters — structs, enums, interfaces, type aliases, and functions:

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

type Callback[T, R = ()] = fn(T) -> R

interface Serializer[T, F = str]
    fn serialize(self, value: T) -> F
end

When fewer type arguments are supplied than total parameters, the compiler fills in defaults left-to-right. Supplying fewer arguments than non-defaulted parameters is a compile error.

Inline constraints and defaults are mutually exclusive on a single type parameter — the grammar allows either T: Constraint or T = Default, but not both. To combine a constraint with a default, use a where clause:

struct SortedList[T = int] where T: Comparable[T]
    # T defaults to int, but must satisfy Comparable[T]
end

Type parameter shadowing is an error — an inner type parameter must not shadow one from an enclosing scope.

Declaration-site variance uses in and out on type parameters (see semantics/functions.md §2.6.3). Structs, enums, interfaces, and type aliases support variance annotations (type aliases are checked for consistency with the underlying type; see data-types.md §4.9). Functions do not:

interface Iterable[out T]
    fn iter(self) -> Iterator[T]
end

interface Comparable[in T]
    fn compare(self, other: T) -> Ordering
end

4.5: Interfaces

Interfaces define behavior contracts. By convention, interface names use PascalCase. Methods are declared with fn, not gen fn. The gen fn syntax is only valid in struct method implementations and top-level/nested function declarations. An interface that wants an iterator-producing method should declare fn iter(self) -> Iterator[T]; implementing structs may use gen fn iter(self) -> Generator[T] which satisfies the requirement through return type covariance (see §4.5 verification rules below). Methods may optionally provide a default body — an implementation used by any conforming struct that does not supply its own:

interface Storage[T]
    fn read(self, address: int) -> T
    fn write(mut self, address: int, value: T)
end

Required signatures (no body) and default implementations (with body) can be freely mixed in the same interface:

interface Greeter
    fn name(self) -> str                 # required — no default

    fn greeting(self) -> str             # default implementation
        return "Hello, {self.name()}"
    end
end

Default method semantics. Inside a default method body, self has the interface type — only methods declared on the interface itself (or inherited through supertrait constraints) are available. The default body cannot call methods that exist only on a particular implementing struct.

Struct implementations take precedence. If a struct provides its own implementation of a method, that implementation is used unconditionally — no override keyword is required or allowed. If the struct omits the method, the interface's default is used. If the interface has no default and the struct omits the method, the implements declaration is a compile error (the interface is not satisfied).

Conditional methods. A where clause on an interface method — whether it has a default body or not — constrains when the method is part of the interface's contract. When the constraints are not met, the method is simply absent from the type's API for that instantiation; it does not produce a compile error unless called.

For default methods, failing to meet a conditional default's constraint does not prevent the implements declaration from being satisfied — the method is omitted, not missing. A struct may override a conditional default with its own implementation, which may have different or no constraints (the normal "struct implementations take precedence" rule applies).

For required methods (no default body), a where clause means the method must be implemented only when the constraint is satisfied. When the constraint is not met, the method is absent from the type's API — the struct need not provide an implementation, and the implements declaration is still satisfied.

For example, Iterable[T] defines fn join(self, separator: str) -> str where T: ToString — the join method is available on Iterable[int] (since int implements ToString) but absent from Iterable[SomeType] when SomeType does not implement ToString.

Disambiguation. Default method bodies are subject to the same qualified method name rules as struct methods (see below). When a struct implements two interfaces that both provide a default for the same method name and the struct does not supply its own implementation, an unqualified call to that method is a compile error (ambiguous). The caller must use qualified call syntax to specify which default to invoke — exactly the same resolution as for conflicting non-default methods.

Self is not available in interfaces.

Interface conformance requires explicit implements declaration. A struct must declare which interfaces it satisfies using the implements keyword in the struct header:

struct TypeName implements Interface1, Interface2, ...
    # ...
end

Self resolves before conformance checking. When a struct uses Self in a method signature, the compiler substitutes the concrete type before comparing against the interface. For example, when Point defines pub fn eq(self, other: Self) -> bool, Self resolves to Point, producing the effective signature fn eq(self, other: Point) -> bool. This is then matched against PartialEq[T]'s requirement fn eq(self, other: T) -> bool with T = Point — and the signatures match. The interface never sees Self; it only sees the fully resolved concrete types.

Conforming methods must match interface visibility. A method implementing an interface must have the same visibility as the interface itself:

A method with lower visibility does not satisfy the interface (e.g., a pkg method does not satisfy a pub interface). A method with higher visibility is also forbidden — a pub method cannot implement a pkg interface, as this would leak the internal interface contract to external consumers who should not depend on package-internal behavior.

Example:

pkg interface InternalEq[T]
    fn eq(self, other: T) -> bool
end

pub struct Point implements InternalEq[Point]
    pub x: int
    pub y: int
    
    # Must be pkg to match interface visibility
    pkg fn InternalEq[Point].eq(self, other: Point) -> bool
        return self.x == other.x
    end
    
    # Error: cannot use pub for a pkg interface method
    # pub fn InternalEq[Point].eq(self, other: Point) -> bool
    #     ...
    # end
end

This ensures that interface contracts remain within their intended visibility scope — callers can always invoke methods from visible interfaces, but cannot access or depend on methods implementing non-visible interfaces.

Method qualification for name disambiguation. When a struct implements multiple interfaces with methods of the same name, it must use qualified method names to disambiguate. There is no default resolution — the caller must use qualified syntax to specify which method to invoke:

struct Point implements Eq[Point], Hash
    pub x: int
    pub y: int

    # Qualified method — implementing PartialEq interface (implied by Eq)
    pub fn PartialEq[Point].eq(self, other: Point) -> bool
        return self.x == other.x && self.y == other.y
    end

    # Qualified method — implementing Hash interface
    pub fn Hash.hash(self) -> int
        return self.x * 31 + self.y
    end
end

# Usage: call with normal dot syntax (no ambiguity here — only one eq)
p1 = Point { x: 3, y: 4 }
p2 = Point { x: 3, y: 4 }
result = p1.eq(p2)

# Qualified call syntax also works (optional when unambiguous)
result = p1.PartialEq[Point].eq(p2)

Qualified call syntax uses the same form as qualified definitions: obj.InterfaceName[TypeArgs].methodName(...). When only one method with a given name is visible, qualification is optional — unqualified dot syntax works. When multiple methods with the same name are visible, qualification is required to disambiguate; an unqualified call is a compile error.

Call-site resolution rules: When a struct has multiple methods with the same name (implementing different interfaces), unqualified method calls are resolved as follows:

  1. If multiple methods with the same name are visible at the call site and no qualification is provided → compile error (ambiguous). Use qualified call syntax to disambiguate.
  2. If only one method with that name is visible (others hidden by visibility) → use that one. This allows a struct to implement both pub and pkg interfaces with the same method name, as long as their visibility differs.
  3. If no methods with that name are visible → compile error (does not exist).

Example with multiple interfaces and visibility-based resolution:

pkg interface InternalEq[T]
    fn eq(self, other: T) -> bool
end

pub struct Point implements Eq[Point], InternalEq[Point]
    pub x: int
    pub y: int
    
    # Public equality — strict comparison (satisfies PartialEq, implied by Eq)
    pub fn PartialEq[Point].eq(self, other: Point) -> bool
        return self.x == other.x && self.y == other.y
    end
    
    # Package-internal equality — lenient comparison
    pkg fn InternalEq[Point].eq(self, other: Point) -> bool
        return self.x == other.x  # only compare x
    end
end

# Inside the package: both eq methods are visible
# - p1.eq(p2) is ambiguous → compile error
# - p1.PartialEq[Point].eq(p2) calls the public strict comparison
# - p1.InternalEq[Point].eq(p2) calls the package-internal lenient comparison
# - p1 == p2 always calls PartialEq[Point].eq (operator has fixed desugaring)

# Outside the package: only pub methods visible
# - p1.eq(p2) unambiguously calls PartialEq[Point].eq (pkg method is hidden)
# - p1 == p2 also works (calls the pub PartialEq[Point].eq method)

If no name collision exists, methods can be written without qualification (the qualification is optional but allowed for clarity):

struct Person implements Eq[Person], ToString
    pub name: str
    age: int

    # Unqualified methods — no collision, so qualification is optional
    pub fn eq(self, other: Self) -> bool
        return self.name == other.name && self.age == other.age
    end

    pub fn toString(self) -> str
        return "Person({self.name}, {self.age})"
    end
end

Conditional conformance with where clauses. Generic structs can use where clauses to specify that interface conformance is conditional on type parameters:

struct Box[T] implements Eq[Box[T]], ToString where T: Eq[T] & ToString
    pub value: T

    pub fn PartialEq[Box[T]].eq(self, other: Box[T]) -> bool
        return self.value == other.value
    end

    pub fn ToString.toString(self) -> str
        return "Box({self.value})"
    end
end

Multiple constraints on the same type parameter use & syntax, just like inline type parameter constraints. The implements clause is only satisfied when all constraints in the where clause are met.

Interface methods may declare their own type parameters. Method-level type parameters use the same [TypeParams] syntax as struct methods and functions, and may include inline constraints and where clauses. This enables default method implementations like map[U] on Iterable[T]:

interface Iterable[out T]
    fn iter(self) -> Iterator[T]

    fn map[U](self, f: fn(T) -> U) -> Iterator[U]
        # default implementation using for-in loop
    end
end

Verification at struct declaration. The compiler checks that all declared interfaces in the implements clause are satisfied. For each interface, the struct must have matching methods (qualified or unqualified) with matching visibility (pub interface → pub method, pkg interface → pkg method) and compatible signatures. Parameter types must match exactly, and the self parameter's mutability must match exactly — an interface method declaring self requires the implementing method to use self, and an interface declaring mut self requires mut self. Return types allow covariance — the implementing method's return type may be a subtype of the interface's declared return type, consistent with function type variance (§2.6.2). This is most commonly relevant for gen fn methods: a method returning Generator[T] satisfies an interface requiring Iterator[T], because Generator[T] conforms to Iterator[T]. If any interface is not satisfied, the struct declaration is a compile error.

struct MemoryStorage implements Storage[str]
    data: Map[int, str]

    pub fn new()
        return Self { data: {} }
    end

    pub fn read(self, address: int) -> str
        return self.data[address]
    end

    pub fn write(mut self, address: int, value: str)
        self.data[address] = value
    end
end

fn useStorage(mut s: Storage[str])
    s.write(0, "hello")
end

useStorage(MemoryStorage.new())   # ok — MemoryStorage implements Storage[str]

Interface types. Interfaces can be used as types in any position where a type is expected — variable declarations, function parameters, return types, collection element types, and generic type arguments. A value of concrete type S is assignable to an interface type I when S declares implements I (or when S is a built-in type with compiler-provided conformance to I).

x: ToString = "hello"              # ok — str satisfies ToString
items: List[Comparable[int]] = []  # ok — interface as type argument
fn format(val: ToString) -> str    # ok — interface as parameter type
    return val.toString()
end

When a binding has an interface type, only the methods declared by that interface (and its supertraits) are available. The concrete type's other methods are not accessible:

fn printIt(val: ToString)
    println(val.toString())   # ok — toString is part of ToString
    # val.foo()                # error — foo is not part of ToString
end

The runtime representation of interface-typed values (boxing, vtables, monomorphization, or other strategies) is a back-end concern and is not specified.

4.5.1: The PartialEq[T] and Eq[T] Interfaces

PartialEq[T] is a built-in interface that gates the == and != operators:

interface PartialEq[in T]
    fn eq(self, other: T) -> bool
end

Eq[T] is a marker interface that refines PartialEq[T] with a reflexivity guarantee (x == x is always true). It uses a supertrait constraint — any type implementing Eq[T] must also implement PartialEq[T]:

interface Eq[in T] where Self: PartialEq[T]
end

Supertrait constraints (where Self: ... on an interface) require that any implementing type also satisfies the referenced interface. Declaring implements Eq[T] automatically implies PartialEq[T] — there is no need to list both in the implements clause.

The built-in types bool, byte, int, uint, and str implement both PartialEq and Eq. float implements PartialEq[float] but not Eq[float] (NaN violates reflexivity). User-defined structs must explicitly declare implements Eq[T] (or implements PartialEq[T] if reflexivity does not hold) and provide an eq method:

struct Point implements Eq[Point]
    pub x: int
    pub y: int

    pub fn eq(self, other: Self) -> bool
        return self.x == other.x && self.y == other.y
    end
end

a = Point { x: 1, y: 2 }
b = Point { x: 1, y: 2 }
a == b      # true — calls Point.eq

Without declaring implements PartialEq[T] (or Eq[T]) or without an eq method, using == or != on a struct is a type error.

Enums have built-in equality== and != work on all enum types without requiring an explicit implements declaration. For enums with associated data, equality compares both the variant tag and the associated values (which must themselves satisfy PartialEq).

See stdlib/interfaces.md for the full specification.

Link copied to clipboard!