Generics

Types and methods can be generic, meaning that instead of supporting a single fixed type (e.g. String), they can operate on many different types. Take the following class for example:

class Box {
  let @value: String
}

This Box type can only store String values, but what if we want to also store Float or a custom type of sorts? Generic types and methods allow us to solve this problem, without having to copy-paste large amounts of code.

Generic types

Generic classes are defined as follows:

class Box[T] {
  let @value: T
}

By defining the type as Box[T] instead of just Box, we've made it generic. In this particular case the type defines a single generic type parameter: T, of which the name is arbitrary (e.g. we could've picked Kittens, VALUE, or something else). The type of the value field is defined as T, instead of String or Float. We can now create instances of this type that use different types for the value field:

Box(value: 'test')
Box(value: 42)
Box(value: 1.123)

We can of course define more than just one type parameter:

class Pair[A, B] {
  let @a: A
  let @b: B
}

Pair(a: 'test', b: 42)

Traits are made generic in the same way:

trait ToBox[T] {
  fn to_box -> Box[T]
}

Generic methods

Just like types, methods can be made generic. Take this method for example:

fn box(value: String) {
  ...
}

Like the Box type we started with, value is typed as String and thus only String values can be passed as arguments to this method. Turning this method into a generic method is done similar to making types generic:

fn box[T](value: T) {
  ...
}

Now the box method accepts values of different types such as String, Float, and more.

We can also define multiple type parameters just as we can with generic types:

fn example[A, B](value: A, value: B) {
  ...
}

Return types can also be generic:

fn example[T](value: T) -> T {
  value
}

This method takes a value of any type and returns it as-is.

Type parameter requirements

In these examples the type parameters don't specify any sort of requirements a type must meet before it's considered compatible with the type parameter. As such, there's not much we can do with values of these types (other than move them around), as they could be anything. To resolve this, we can define one or more required traits when defining a type parameter:

trait ToString {
  fn to_string -> String
}

class Box[T: ToString] {
  let @value: T
}

Here T: ToString means that for a type to be compatible with T, it must implement the ToString trait. If you try to assign a value of which the type doesn't implement ToString, you'll get a compile-time error.

Type parameters can define multiple required traits as follows:

trait A {}
trait B {}

class Box[T: A + B] {
  let @value: T
}

Here T: A + B means T requires both the traits A and B to be implemented before a type is considered compatible with the type parameter.

The required traits can also be made generic:

trait Equal[A] {}

class Example[B: Equal[B]] {
  ...
}

In this example B: Equal[B] means that for a type Foo to be compatible with B, it must implement the trait Equal[Foo].