Skip to content

Type Classes

Type classes give Onion ad-hoc polymorphism: you can constrain a generic on the operations a type must support, and the compiler supplies the right implementation at each call. They are declared Rust-trait style, with trait, instance, and a [T: Trait] context bound.

Declaring a trait

A trait describes operations over a type parameter:

trait Numeric[T] {
  def zero(): T
  def plus(a: T, b: T): T
}

A trait may have default methods, just like an interface:

trait Greeter {
  def name(): String
  def greet(): String = "Hi " + name()
}

Providing instances

An instance implements a trait for a concrete type:

trait Numeric[T] {
  def zero(): T
  def plus(a: T, b: T): T
}
instance Numeric[Integer] {
  def zero(): Integer = 0
  def plus(a: Integer, b: Integer): Integer = a + b
}
instance Numeric[Long] {
  def zero(): Long = 0L
  def plus(a: Long, b: Long): Long = a + b
}

There is at most one instance per (trait, type) (coherence). Primitive and boxed forms are the same type, so Numeric[Int] and Numeric[Integer] are one instance — declaring both is an error. The rule holds across the whole program: a second instance Numeric[Int] in another module is the same error (E0097).

An instance is found when it is declared in the module of the code that needs it, in the trait's module, in the type's module, or in a file with no module line.

Constrained generics

Write [T: Numeric] to require that T has a Numeric instance. Inside the body, call the trait's methods through Numeric[T]::method(...):

trait Numeric[T] {
  def zero(): T
  def plus(a: T, b: T): T
}
instance Numeric[Integer] {
  def zero(): Integer = 0
  def plus(a: Integer, b: Integer): Integer = a + b
}
instance Numeric[Double] {
  def zero(): Double = 0.0
  def plus(a: Double, b: Double): Double = a + b
}

def sum[T: Numeric](xs: List[T]): T {
  var acc: T = Numeric[T]::zero()
  foreach x: T in xs { acc = Numeric[T]::plus(acc, x) }
  return acc
}

def main(args: String[]): void {
  println(sum([1, 2, 3, 4]))     // => 10
  println(sum([1.5, 2.5, 3.0]))  // => 7.0
}

The compiler resolves the instance for the inferred T at each call and passes it in for you — you never mention it. Calling a constrained function for a type with no instance is a compile error, not a runtime failure.

You can combine a context bound with an extends upper bound and use several, [T extends Comparable[T]: Numeric] or [T: Numeric, U: Numeric].

Ground dictionary access

Trait[ConcreteType]::method(...) works directly, outside any generic:

trait Numeric[T] {
  def plus(a: T, b: T): T
}
instance Numeric[Integer] {
  def plus(a: Integer, b: Integer): Integer = a + b
}

def main(args: String[]): void {
  println(Numeric[Integer]::plus(3, 4))  // => 7
}

Constrained functions calling constrained functions

A constrained function can call another with the same abstract type parameter; the instance is forwarded automatically:

trait Numeric[T] {
  def zero(): T
  def plus(a: T, b: T): T
}
instance Numeric[Integer] {
  def zero(): Integer = 0
  def plus(a: Integer, b: Integer): Integer = a + b
}

def sum[T: Numeric](xs: List[T]): T {
  var acc: T = Numeric[T]::zero()
  foreach x: T in xs { acc = Numeric[T]::plus(acc, x) }
  return acc
}
def sumTwice[T: Numeric](xs: List[T]): T = Numeric[T]::plus(sum(xs), sum(xs))

def main(args: String[]): void {
  println(sumTwice([1, 2, 3]))  // => 12
}

Your own traits

Type classes are not limited to numbers — define any trait you need:

trait Eq[T] {
  def eq(a: T, b: T): Boolean
}
instance Eq[Integer] {
  def eq(a: Integer, b: Integer): Boolean = a == b
}

def allSame[T: Eq](xs: List[T]): Boolean {
  if xs.size() < 2 { return true }
  val head: T = xs.get(0)
  foreach x: T in xs {
    if !Eq[T]::eq(head, x) { return false }
  }
  return true
}

def main(args: String[]): void {
  println(allSame([7, 7, 7]))  // => true
  println(allSame([7, 8, 7]))  // => false
}

Current limitations

The first release focuses on the common cases:

  • Single-parameter traits (trait C[T]).
  • Context bounds on functions and methods (def f[T: C]); a bound on a generic class (class Box[T: C]) parses but is not enforced yet.
  • Trait methods are called explicitly via Trait[T]::method(...); UFCS value.method(...) is not yet available.
  • Instances are for ground types (instance Numeric[Integer]), not parameterized (instance Numeric[List[T]]).
  • There is no built-in Numeric/Eq/Ord yet — define the traits you need.