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.,
Serializablemight requireShow) - 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
+,-,*,/→Numtrait (add,sub,mul,div)<,>,<=,>=→Ordtrait (lt,gt,lte,gte)==,!=→Eqtrait (eq,neq)v[i]→Indextrait (index)v[i] = x→IndexMuttrait (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 forvecAddScalar(v, 2.0)v - 1.0→ looks forvecSubScalar(v, 1.0)v * 3.0→ looks forvecMulScalar(v, 3.0)v / 2.0→ looks forvecDivScalar(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:
- First, it tries to find
Vec.Num.mul(v, 2.0)— but this expectsVec, notFloat - Then, it looks for a function named
vecMulScalarthat takes(Vec, Float) - 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]→Indextrait (index(self, i) -> T)v[i] = x→IndexMuttrait (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
Numtrait for+,-,*,/ - Mixed-type (scalar) operations: Define
typeOpScalarfunctions (e.g.,vecMulScalar) - Index reading: Implement the
Indextrait forv[i] - Index writing: Implement the
IndexMuttrait forv[i] = x - Pretty display: Implement the
Showtrait for REPL output