Traits

Traits in Nostos provide a powerful mechanism for polymorphism, allowing you to define shared behavior that different types can implement. They are similar to interfaces in Java or Go, or type classes in Haskell.

Defining a Trait

A trait defines a set of functions that a type must implement. It specifies the function signatures without providing an implementation.


# Define a 'Show' trait for types that can be converted to a string
trait Show
    show(self) -> String
end

# Define an 'Eq' trait for types that can be compared for equality
trait Eq
    eq(self, other: Self) -> Bool
end

Implementing a Trait for a Type

Use the Type: Trait syntax to implement a trait for a specific type. You must provide an implementation for all functions defined in the trait.


type Point = {x: Float, y: Float}

# Implement the Show trait for Point
Point: Show
    show(self) = "(" ++ self.x.show() ++ ", " ++ self.y.show() ++ ")"
end

# Implement the Eq trait for Point
Point: Eq
    eq(self, other) = self.x == other.x && self.y == other.y
end

main() = {
    p1 = Point(1.0, 2.0)
    p2 = Point(1.0, 2.0)
    p3 = Point(3.0, 4.0)

    println(p1.show()) # Prints "(1.0, 2.0)"
    is_equal1 = p1.eq(p2) # true
    is_equal2 = p1.eq(p3) # false
}

Supertraits (Trait Inheritance)

A trait can require that types implementing it also implement other traits. These required traits are called supertraits. This is useful when your trait builds upon functionality defined in other traits.

# Base trait for things that can be displayed
trait Displayable
    display(self) -> String
end

# Extended trait that requires Displayable
# The ": Displayable" syntax declares Displayable as a supertrait
trait Formattable: Displayable
    formatWith(self, prefix: String) -> String
end

type Person = { name: String, age: Int }

# Must implement Displayable BEFORE implementing Formattable
Person: Displayable
    display(self) = self.name ++ " (age " ++ show(self.age) ++ ")"
end

# Now we can implement Formattable
Person: Formattable
    # Can use display() from the supertrait!
    formatWith(self, prefix) = prefix ++ ": " ++ self.display()
end

main() = {
    p = Person("Alice", 30)
    println(p.display())                  # "Alice (age 30)"
    println(p.formatWith("Employee"))     # "Employee: Alice (age 30)"
}

Multiple Supertraits

A trait can require multiple supertraits by separating them with commas:

# Two base traits
trait Printable
    print(self) -> String
end

trait Comparable
    compare(self, other: Self) -> Int
end

# A trait requiring BOTH Printable and Comparable
trait Sortable: Printable, Comparable
    sortKey(self) -> Int
end

type Score = { player: String, points: Int }

# Implement both supertraits first
Score: Printable
    print(self) = self.player ++ ": " ++ show(self.points)
end

Score: Comparable
    compare(self, other) = self.points - other.points
end

# Now we can implement Sortable
Score: Sortable
    sortKey(self) = self.points
end

Diamond Pattern

Nostos handles the diamond pattern where multiple traits share a common supertrait:

#       Base
#       /   \
#      B     C
#       \   /
#         D
# D requires both B and C, both require Base

trait Base
    getValue(self) -> Int
end

trait B: Base
    getB(self) -> Int
end

trait C: Base
    getC(self) -> Int
end

trait D: B, C
    getD(self) -> Int
end

type MyType = { value: Int }

# Implement in order: Base -> B, C -> D
MyType: Base getValue(self) = self.value end
MyType: B getB(self) = self.value * 2 end
MyType: C getC(self) = self.value * 3 end
MyType: D getD(self) = self.value * 4 end

main() = {
    x = MyType(10)
    # All methods are available
    println(show(x.getValue()))  # 10
    println(show(x.getB()))      # 20
    println(show(x.getC()))      # 30
    println(show(x.getD()))      # 40
}

Important: You must implement supertraits before implementing a trait that requires them. The compiler will give an error if you try to implement a trait without first implementing its supertraits.

# This will cause a compile error!
trait Parent
    parentMethod(self) -> Int
end

trait Child: Parent
    childMethod(self) -> Int
end

type MyType = { x: Int }

# ERROR: Must implement Parent before Child!
MyType: Child
    childMethod(self) = self.x
end

# Fix: Implement Parent first
MyType: Parent parentMethod(self) = self.x end
MyType: Child childMethod(self) = self.x * 2 end

When to Use Supertraits

  • Building on existing behavior: Your trait methods need to call methods from another trait
  • Hierarchical capabilities: More specific traits extend general ones (e.g., Serializable might require Show)
  • Ensuring prerequisites: A type must have certain capabilities before it can have others
  • Code organization: Split large traits into composable pieces

Heterogeneous Collections with Sum Types

A common challenge is creating a list of different types that all share common behavior. For example, you might want a list of shapes where each element could be a Circle, Square, or Triangle. In Nostos, you can achieve this using sum types combined with traits.

The Problem

Generic functions with trait bounds require all elements to be the same concrete type:

# This function requires ALL elements to be the same type T
drawAll[T: Drawable](items: List[T]) -> List[String] =
    items.map(x => x.draw())

# Works - all elements are Circle
drawAll([Circle(1.0), Circle(2.0)])  # OK

# Doesn't work - mixed types!
drawAll([Circle(1.0), Square(2.0)])  # ERROR: type mismatch

The Solution: Sum Type Wrapper

Create a sum type that wraps all the concrete types, then implement the trait on the sum type by delegating to each wrapped type:

# 1. Define the trait
trait Drawable
    draw(self) -> String
end

# 2. Define concrete types
type Circle = { radius: Float }
type Square = { side: Float }
type Triangle = { base: Float, height: Float }

# 3. Implement trait for each concrete type
Circle: Drawable
    draw(self) = "Circle(r=" ++ show(self.radius) ++ ")"
end

Square: Drawable
    draw(self) = "Square(s=" ++ show(self.side) ++ ")"
end

Triangle: Drawable
    draw(self) = "Triangle(b=" ++ show(self.base) ++ ")"
end

# 4. Create sum type wrapping all variants
type Shape = C(Circle) | S(Square) | T(Triangle)

# 5. Implement trait on sum type via delegation
Shape: Drawable
    draw(self) = match self {
        C(c) -> c.draw(),
        S(s) -> s.draw(),
        T(t) -> t.draw()
    }
end

# 6. Now you can have heterogeneous collections!
main() = {
    shapes: List[Shape] = [
        C(Circle(1.0)),
        S(Square(2.0)),
        T(Triangle(3.0, 4.0))
    ]

    # Map over mixed types using the trait method
    drawings = shapes.map(s => s.draw())
    # ["Circle(r=1)", "Square(s=2)", "Triangle(b=3)"]

    println(show(drawings))
}

Using Higher-Order Functions

The sum type works seamlessly with map, filter, and other list operations:

# Add an area method to our trait
trait Drawable
    draw(self) -> String
    area(self) -> Float
end

Circle: Drawable
    draw(self) = "Circle"
    area(self) = 3.14159 * self.radius * self.radius
end

Square: Drawable
    draw(self) = "Square"
    area(self) = self.side * self.side
end

type Shape = C(Circle) | S(Square)

Shape: Drawable
    draw(self) = match self { C(c) -> c.draw(), S(s) -> s.draw() }
    area(self) = match self { C(c) -> c.area(), S(s) -> s.area() }
end

main() = {
    shapes: List[Shape] = [
        C(Circle(2.0)),      # area = 12.56
        S(Square(3.0)),      # area = 9.0
        C(Circle(1.0)),      # area = 3.14
        S(Square(5.0))       # area = 25.0
    ]

    # Map: get all drawings
    names = shapes.map(s => s.draw())
    # ["Circle", "Square", "Circle", "Square"]

    # Filter: only shapes with area > 10
    bigShapes = shapes.filter(s => s.area() > 10.0)
    # [C(Circle(2.0)), S(Square(5.0))]

    # Fold: sum all areas
    totalArea = shapes.foldl(0.0, (acc, s) => acc + s.area())
    # 49.7

    # Pattern match to recover concrete type
    circles = shapes.filter(s => match s { C(_) -> true, _ -> false })
}

With Multiple Traits

Sum types can implement multiple traits, including those with supertraits:

trait Printable
    toString(self) -> String
end

trait Serializable: Printable
    serialize(self) -> String
end

type Dog = { name: String }
type Cat = { name: String }

# Implement both traits for concrete types
Dog: Printable
    toString(self) = "Dog:" ++ self.name
end

Dog: Serializable
    serialize(self) = "{\"type\":\"dog\",\"name\":\"" ++ self.name ++ "\"}"
end

Cat: Printable
    toString(self) = "Cat:" ++ self.name
end

Cat: Serializable
    serialize(self) = "{\"type\":\"cat\",\"name\":\"" ++ self.name ++ "\"}"
end

# Sum type implements both (must implement Printable first due to supertrait)
type Pet = D(Dog) | C(Cat)

Pet: Printable
    toString(self) = match self { D(d) -> d.toString(), C(c) -> c.toString() }
end

Pet: Serializable
    serialize(self) = match self { D(d) -> d.serialize(), C(c) -> c.serialize() }
end

main() = {
    pets: List[Pet] = [D(Dog("Rex")), C(Cat("Whiskers")), D(Dog("Max"))]

    # Use Printable methods
    names = pets.map(p => p.toString())
    # ["Dog:Rex", "Cat:Whiskers", "Dog:Max"]

    # Use Serializable methods
    json = "[" ++ String.join(pets.map(p => p.serialize()), ",") ++ "]"
    println(show(names))
}

Generic Functions with Sum Types

Since the sum type implements the trait, it works with generic trait-bounded functions:

# Generic function accepting any Drawable
drawAll[T: Drawable](items: List[T]) -> List[String] =
    items.map(x => x.draw())

# Works with homogeneous lists
circles = [Circle(1.0), Circle(2.0)]
drawAll(circles)  # OK - List[Circle]

# Works with heterogeneous lists via sum type!
shapes: List[Shape] = [C(Circle(1.0)), S(Square(2.0))]
drawAll(shapes)   # OK - List[Shape] where Shape: Drawable

Sum Type Pattern: Trade-offs

Advantages

  • No runtime overhead (no vtables or boxing)
  • Type-safe with exhaustive pattern matching
  • Can recover concrete type when needed
  • Works with all existing language features

Trade-offs

  • Must wrap values: C(Circle(...))
  • Closed world: adding types requires modifying sum type
  • Boilerplate: must delegate each trait method
  • All variants must be known at compile time

Tip: This pattern is ideal when you have a known, fixed set of types that share behavior. For truly open-ended polymorphism where new types can be added without modifying existing code, you would need trait objects (dynamic dispatch), which is a more advanced feature.

Generic Traits and Implementations

Traits can be generic, and you can implement them for generic types, often with constraints on the type parameters.


# Option[T] = Some(T) | None is built-in - use it directly!

# Implement Show for Option[T], but only if T itself implements Show
Option[T]: Show when T: Show
    show(None()) = "None"
    show(Some(x)) = "Some(" ++ x.show() ++ ")"
end

main() = {
    opt_int = Some(42)
    opt_none: Option[Int] = None()

    println(opt_int.show())  # Prints "Some(42)"
    println(opt_none.show()) # Prints "None"
}

Using Traits in Functions

Functions can accept arguments that implement a specific trait, allowing them to work polymorphically with any type that satisfies the trait's contract.


# A function that can print any 'Show'able thing
print_showable(item: T) when T: Show = {
    println("Item: " ++ item.show())
}

main() = {
    p = Point(5.0, 10.0)
    num = 123
    text = "Hello Traits!"

    print_showable(p)     # Prints "Item: (5.0, 10.0)"
    print_showable(num)   # Prints "Item: 123" (Int has a built-in Show impl)
    print_showable(text)  # Prints "Item: Hello Traits!" (String has a built-in Show impl)
}

Defining Your Own Traits

Beyond standard traits like Show and Eq, you can define traits that capture domain-specific behavior. Here are practical examples of custom traits.

Example: Serializable Trait

A trait for types that can serialize to and from a string format:

# Define a trait for serialization
trait Serializable
    serialize(self) -> String
end

type Config = { host: String, port: Int, debug: Bool }

Config: Serializable
    serialize(self) = self.host ++ ":" ++ show(self.port) ++ ":" ++ show(self.debug)
end

# Deserialization as a separate function (static methods in traits are advanced)
deserializeConfig(data: String) -> Config = {
    parts = String.split(data, ":")
    Config(parts.get(0), String.toInt(parts.get(1)), parts.get(2) == "true")
}

main() = {
    config = Config("localhost", 8080, true)
    saved = config.serialize()           # "localhost:8080:true"
    loaded = deserializeConfig(saved)    # Config back from string
    println(saved)
}

Example: Drawable Trait

A trait for shapes that can be drawn and measured:

# Define a trait for drawable shapes
trait Drawable
    draw(self) -> String
    area(self) -> Float
    perimeter(self) -> Float
end

type Circle = { radius: Float }
type Rectangle = { width: Float, height: Float }

Circle: Drawable
    draw(self) = "O (radius=" ++ self.radius.show() ++ ")"
    area(self) = 3.14159 * self.radius * self.radius
    perimeter(self) = 2.0 * 3.14159 * self.radius
end

Rectangle: Drawable
    draw(self) = "[" ++ self.width.show() ++ "x" ++ self.height.show() ++ "]"
    area(self) = self.width * self.height
    perimeter(self) = 2.0 * (self.width + self.height)
end

# Works with any Drawable
print_shape_info(shape: T) when T: Drawable = {
    println(shape.draw())
    println("  Area: " ++ shape.area().show())
    println("  Perimeter: " ++ shape.perimeter().show())
}

main() = {
    circle = Circle(5.0)
    rect = Rectangle(4.0, 3.0)

    print_shape_info(circle)
    print_shape_info(rect)
}

Example: Validator Trait

A trait for types that can validate themselves:

# Define a validation trait
trait Validator
    validate(self) -> Result[(), String]
    isValid?(self) -> Bool
end

type Email = { address: String }
type Age = { value: Int }

Email: Validator
    validate(self) = {
        if self.address.contains?("@") then Ok(())
        else Err("Invalid email: missing @")
    }
    isValid?(self) = self.address.contains?("@")
end

Age: Validator
    validate(self) = {
        if self.value < 0 then Err("Age cannot be negative")
        else if self.value > 150 then Err("Age seems unrealistic")
        else Ok(())
    }
    isValid?(self) = self.value >= 0 && self.value <= 150
end

# Validate a list of items
validate_all(items: List[T]) when T: Validator = {
    items.map(item => item.validate())
         .filter(r => match r { Err(_) -> true; _ -> false })
}

main() = {
    email = Email("user@example.com")
    bad_email = Email("invalid")
    age = Age(25)

    println(email.isValid?())      # true
    println(bad_email.isValid?())  # false
    println(age.validate())        # Ok(())
}

When to Define Custom Traits

  • Shared behavior: Multiple types need the same set of operations
  • Polymorphism: You want functions to work with any type that has certain capabilities
  • Decoupling: Separate "what" from "how" - define interface, implement details per type
  • Testing: Mock implementations can satisfy the same trait as real ones

Operator Overloading

Operators in Nostos can be overloaded for custom types by implementing the appropriate trait. When you use an operator on a custom type that implements the relevant trait, the compiler automatically dispatches to your trait method.

Operator to Trait Mapping

  • +, -, *, /Num trait (add, sub, mul, div)
  • <, >, <=, >=Ord trait (lt, gt, lte, gte)
  • ==, !=Eq trait (eq, neq)
  • v[i]Index trait (index)
  • v[i] = xIndexMut trait (indexMut)

# Define a 2D vector type
type Vec2 = { x: Int, y: Int }

# Define the Num trait for arithmetic operations
trait Num
    add(self, other: Self) -> Self
    sub(self, other: Self) -> Self
    mul(self, other: Self) -> Self
    div(self, other: Self) -> Self
end

# Implement Num for Vec2 to enable operator overloading
Vec2: Num
    add(self, other: Vec2) -> Vec2 = Vec2(self.x + other.x, self.y + other.y)
    sub(self, other: Vec2) -> Vec2 = Vec2(self.x - other.x, self.y - other.y)
    mul(self, other: Vec2) -> Vec2 = Vec2(self.x * other.x, self.y * other.y)
    div(self, other: Vec2) -> Vec2 = Vec2(self.x / other.x, self.y / other.y)
end

main() = {
    v1 = Vec2(1, 2)
    v2 = Vec2(3, 4)

    # Now you can use operators directly!
    v3 = v1 + v2          # Calls Vec2.Num.add
    println(v3.x)         # Prints 4
    println(v3.y)         # Prints 6

    # Chained operations work too
    v4 = v1 + v2 + v1     # (1+3+1, 2+4+2) = (5, 8)

    # Primitive types still use built-in operators
    a = 10 + 5            # Uses primitive Int addition
}

Note: Operator overloading is resolved at compile time. The compiler checks if the left operand's type implements the relevant trait and generates a direct call to the trait method, ensuring zero runtime overhead.

Mixed-Type Operations (Scalars)

What happens when you want to multiply a vector by a scalar, like myVec * 2.0? The Num trait requires both operands to be the same type (Self), so Vec * Float won't match mul(self, other: Self).

Nostos solves this with a naming convention: when an operator doesn't match the trait method, the compiler looks for a standalone function named {typeLower}{Op}Scalar. For example, Vec * 2.0 will call vecMulScalar(v, 2.0).

Scalar Function Naming Convention

  • v + 2.0 → looks for vecAddScalar(v, 2.0)
  • v - 1.0 → looks for vecSubScalar(v, 1.0)
  • v * 3.0 → looks for vecMulScalar(v, 3.0)
  • v / 2.0 → looks for vecDivScalar(v, 2.0)
# A vector type with both vector-vector and vector-scalar operations
type Vec = { data: List }

# Num trait for vector-vector operations
trait Num
    add(self, other: Self) -> Self
    sub(self, other: Self) -> Self
    mul(self, other: Self) -> Self
    div(self, other: Self) -> Self
end

Vec: Num
    add(self, other: Vec) -> Vec = Vec(zipWith((a, b) => a + b, self.data, other.data))
    sub(self, other: Vec) -> Vec = Vec(zipWith((a, b) => a - b, self.data, other.data))
    mul(self, other: Vec) -> Vec = Vec(zipWith((a, b) => a * b, self.data, other.data))
    div(self, other: Vec) -> Vec = Vec(zipWith((a, b) => a / b, self.data, other.data))
end

# Scalar operations - the compiler finds these by naming convention
pub vecAddScalar(v: Vec, s: Float) -> Vec = Vec(v.data.map(x => x + s))
pub vecSubScalar(v: Vec, s: Float) -> Vec = Vec(v.data.map(x => x - s))
pub vecMulScalar(v: Vec, s: Float) -> Vec = Vec(v.data.map(x => x * s))
pub vecDivScalar(v: Vec, s: Float) -> Vec = Vec(v.data.map(x => x / s))

# Constructor
pub vec(data: List) -> Vec = Vec(data)

main() = {
    v = vec([1.0, 2.0, 3.0])

    # Vector + Vector (uses Num trait)
    v2 = v + v                    # Vec([2.0, 4.0, 6.0])

    # Vector * Scalar (uses vecMulScalar)
    scaled = v * 2.0              # Vec([2.0, 4.0, 6.0])

    # Can chain operations naturally
    result = v * 2.0 + vec([1.0, 1.0, 1.0])  # Vec([3.0, 5.0, 7.0])

    println(show(scaled.data))    # [2.0, 4.0, 6.0]
}

How It Works

When the compiler sees v * 2.0 where v: Vec:

  1. First, it tries to find Vec.Num.mul(v, 2.0) — but this expects Vec, not Float
  2. Then, it looks for a function named vecMulScalar that takes (Vec, Float)
  3. If found, it generates a call to vecMulScalar(v, 2.0)

Index Operator Overloading

The [] operator can be overloaded to provide array-like access to your custom types. Implement the Index trait to enable v[i] syntax for reading, and optionally IndexMut for v[i] = value syntax.

Index Traits

  • v[i]Index trait (index(self, i) -> T)
  • v[i] = xIndexMut trait (indexMut(self, i, value) -> Self)
# Index trait for reading: v[i]
trait Index
    index(self, i: Int) -> Float
end

# IndexMut trait for writing: v[i] = x
trait IndexMut
    indexMut(self, i: Int, value: Float) -> Self
end

type Vec = { data: List }

Vec: Index
    index(self, i: Int) -> Float = self.data.get(i)
end

Vec: IndexMut
    # Returns a new Vec with the element at index i replaced
    indexMut(self, i: Int, value: Float) -> Vec = {
        newData = self.data.set(i, value)
        Vec(newData)
    }
end

pub vec(data: List) -> Vec = Vec(data)

main() = {
    v = vec([1.0, 2.0, 3.0])

    # Reading with index operator
    first = v[0]              # 1.0
    second = v[1]             # 2.0

    # Writing with index operator (returns new Vec)
    v2 = v[0] = 10.0          # Vec([10.0, 2.0, 3.0])

    # Chain index operations
    v3 = v[0] = 10.0
    v4 = v3[2] = 30.0         # Vec([10.0, 2.0, 30.0])

    println(show(v[0]))       # 1.0
    println(show(v2[0]))      # 10.0
}

Important: Like all values in Nostos, index assignment creates a new value rather than mutating in place. The expression v[0] = 10.0 returns a new Vec with the modified element. This preserves immutability by default.

Real-World Example: Linear Algebra Library

Here's how these features combine in a real linear algebra extension, where you want natural mathematical syntax:

# Using the nalgebra extension
import nalgebra
use nalgebra.*

main() = {
    # Create vectors
    v1 = vec([1.0, 2.0, 3.0])
    v2 = vec([4.0, 5.0, 6.0])

    # Vector + Vector (Num trait)
    sum = v1 + v2             # Vec[5.0, 7.0, 9.0]

    # Vector * Scalar (vecMulScalar)
    scaled = v1 * 2.0         # Vec[2.0, 4.0, 6.0]

    # Index access (Index trait)
    first = v1[0]             # 1.0

    # Index assignment (IndexMut trait)
    modified = v1[0] = 10.0   # Vec[10.0, 2.0, 3.0]

    # All operations chain naturally
    result = (v1 + v2) * 0.5  # Vec[2.5, 3.5, 4.5]

    # Show trait gives pretty output
    println(show(result))     # Vec[2.5, 3.5, 4.5]
}

Summary: Operator Overloading in Nostos

  • Same-type operations: Implement the Num trait for +, -, *, /
  • Mixed-type (scalar) operations: Define typeOpScalar functions (e.g., vecMulScalar)
  • Index reading: Implement the Index trait for v[i]
  • Index writing: Implement the IndexMut trait for v[i] = x
  • Pretty display: Implement the Show trait for REPL output