Generics in Scala

Table of Contents

Understanding Generics

Generics in Scala allow you to write classes and methods that can work with different types while preserving type safety. Instead of writing duplicate code for each data type, you define the class or method once and supply the type as a parameter when you use it. Type parameters are written inside square brackets []. By convention, a single capital letter likeA is often used, though any valid identifier is acceptable.

1class Stack[A]:
2  private var elements: List[A] = Nil
3
4  def push(x: A): Unit =
5    elements = x :: elements
6
7  def peek: A = elements.head
8
9  def pop(): A =
10    val currentTop = peek
11    elements = elements.tail
12    currentTop

In this example, the Stack class is generic because it is defined with the type parameter A. This means you can create a stack of integers, strings, fruits, or any other type, and the compiler will ensure that only elements of the correct type are pushed onto that stack.

For instance, if you declare a Stack[Fruit], you can push any object that is a Fruit or a subtype of Fruit. This provides flexibility while still preventing accidental misuse, such as pushing an integer into a stack meant for fruits.

1class Fruit
2class Apple extends Fruit
3class Banana extends Fruit
4
5val stack = Stack[Fruit]
6val apple = Apple()
7val banana = Banana()
8
9stack.push(apple)
10stack.push(banana)

Invariance

Variance in Scala determines how subtyping between types affects subtyping between parameterized types. In other words, it answers the question: if Cat is a subtype of Animal, is Box[Cat] also a subtype of Box[Animal]? The answer depends on the variance annotation.

1class Foo[+A] // covariant
2class Bar[-A] // contravariant
3class Baz[A]  // invariant

By default, type parameters in Scala are invariant. This means that even though Cat is a subtype of Animal, the compiler does not consider Box[Cat] to be a subtype of Box[Animal]. This rule ensures type safety when dealing with mutable data.

1class Box[A](var content: A)
2
3abstract class Animal:
4  def name: String
5
6case class Cat(name: String) extends Animal
7case class Dog(name: String) extends Animal

At first glance, it might seem natural to treat Box[Cat] as a Box[Animal], since a Cat is an Animal. However, the compiler prevents this:

1val myCatBox: Box[Cat] = Box[Cat](Cat("Felix"))
2val myAnimalBox: Box[Animal] = myCatBox // this doesn't compile
3val myAnimal: Animal = myAnimalBox.content

Why? Because Box is mutable. If the assignment were allowed, we could put a Dog into what is actually a Box[Cat]. That would break the guarantee that a Box[Cat] only ever contains cats.

1myAnimalBox.content = Dog("Fido")

Now the box, which the type system believes holds only Cat values, actually contains a Dog. If we later try to retrieve a Cat from it, we'd end up with a Dog, violating type safety:

1val myCat: Cat = myCatBox.content // myCat would be Fido the dog!

This is why Scala defaults to invariance: it avoids unsound substitutions for mutable types. A general rule of thumb is that if a type both produces and consumes values, it must be invariant to remain safe. Immutable or read-only types, on the other hand, can often be covariant since they never accept values that could break assumptions.

Covariance

In the previous example, the issue arose because we could both read and write to the box. Allowing writes meant a Box[Cat]could be misused as a Box[Animal] by inserting a Dog, breaking type safety. But what if the box were immutable—so we could only take values out, never put new ones in? In that case, a Box[Cat] could safely be treated as a Box[Animal], since every cat is an animal.

1class ImmutableBox[+A](val content: A)
2val catBox: ImmutableBox[Cat] = ImmutableBox(Cat("Felix"))
3val animalBox: ImmutableBox[Animal] = catBox // compiles fine

This is called covariance. If a type parameter T is marked with +, then wheneverA is a subtype ofB, Cov[A] is considered a subtype of Cov[B].

1def printAnimalNames(animals: List[Animal]): Unit =
2  animals.foreach(animal => println(animal.name))
3
4val cats: List[Cat] = List(Cat("Whiskers"), Cat("Tom"))
5val dogs: List[Dog] = List(Dog("Fido"), Dog("Rex"))
6
7// prints: Whiskers, Tom
8printAnimalNames(cats)
9
10// prints: Fido, Rex
11printAnimalNames(dogs)

Here, List in Scala is covariant. A list of cats can be safely passed to a function expecting a List[Animal], since the function only reads from the list—it never inserts other animals into it. Covariance works perfectly for “read-only” containers.

To preserve type safety, covariant type parameters are only allowed in output positions. This means they can be safely returned from methods, but cannot be used as method parameters (input positions). If they were, we could accidentally insert the wrong subtype into a container. For example, if List were mutable and covariant, we could add a Cat into a List[Dog], which would clearly break type safety. By restricting covariance to outputs only, Scala prevents these unsafe operations.

1// Base class and subclass
2abstract class Animal {
3  def sound: String
4}
5case class Dog(name: String) extends Animal {
6  val sound = "Woof"
7}
8
9// A covariant Box
10class Box[+A](val item: A) {
11  def getItem: A = item // safe: only returns the item
12}
13
14object CovarianceExample extends App {
15  val dogBox: Box[Dog] = new Box(Dog("Buddy"))
16  val animalBox: Box[Animal] = dogBox // valid due to covariance
17
18  println(animalBox.getItem.sound) // Output: Woof
19}

In this example, Box is immutable and only allows reading its content. This makes it safe to treat a Box[Dog] as a Box[Animal]. Since nothing can be written into the box, no unsafe substitutions are possible, and covariance works as intended.

Contravariance

Contravariance applies to types that consume values rather than producing them. A common case is function parameters: if a function can handle values of type Animal, it can also handle values of type Dog, since every Dog is an Animal.

1abstract class Printer[-S] {
2  def print(value: S): Unit
3}
4
5class AnimalPrinter extends Printer[Animal] {
6  def print(animal: Animal): Unit =
7    println(s"Printing animal: ${animal.name}")
8}
9
10class Animal(val name: String)
11class Dog(name: String) extends Animal(name)

Notice that Printer is contravariant in S. This means the subtyping direction is reversed: if Dog <: Animal, then Printer[Animal] <: Printer[Dog]. In other words, wherever a Printer[Dog] is expected, you can safely use a Printer[Animal], because an AnimalPrinter knows how to handle any Animal (and a Dog is just a specific kind of Animal).

1val animalPrinter: Printer[Animal] = new AnimalPrinter()
2val dogPrinter: Printer[Dog] = animalPrinter // Allowed due to contravariance
3
4val myDog = new Dog("Buddy")
5dogPrinter.print(myDog) // Prints: "Printing animal: Buddy"

In general, with a contravariant type Contra[-T], the subtyping relationship is reversed: if Dog is a subtype of Animal, then Contra[Animal] is a subtype of Contra[Dog].

Covariance vs Contravariance

1class SomeThing[+T] {
2  def method(a: T) = {...}   // Error: not allowed
3}
4
5class SomeThing[-T] {
6  def method(a: T) = {...}   // Allowed
7}

Variance controls how subtyping between types affects subtyping between parameterized classes. The key rule is: covariance works safely only when the type appears in output positions (like return values), while contravariance works safely when the type appears in input positions (like method parameters).

With covariance (+T), if Int <: AnyVal, then SomeThing[Int] <: SomeThing[AnyVal]. That means from the outside, a method like:

1def method(a: Int)

would look like:

1def method(a: AnyVal)

This is unsafe: you could now pass a Double into something that was designed to only handle Int. The actual object didn’t change — only how the type system views it. That’s why Scala disallows method parameters with covariant type arguments.

With contravariance (-T), the relationship is reversed: if Int <: AnyVal, then SomeThing[AnyVal] <: SomeThing[Int]. A method like:

1def method(a: AnyVal)

is seen as:

1def method(a: Int)

This is safe: if you can handle all AnyVals, you can certainly handle an Int.

For return types, the situation flips: covariant returns are safe (an Int can always be treated as an AnyVal), but contravariant returns are unsafe (a method promising an AnyVal might try to return a Double where the caller expects specifically an Int).

Lower Bound Types

Lower bounds in Scala are used to express that a type parameter must be a supertype of another type. They are useful when working with covariant classes that still need to accept values of different (but related) types. Let's explore this concept using a queue implementation.

1class Queue[+T](private val leading: List[T], trailing: List[T]) {
2    def head(): T 
3    def tail(): List[T] 
4    def enqueue(x: T): Queue[T]
5}

Here, Queue is defined as covariant in T (using +T), meaning that Queue[String] should be a subtype of Queue[Any]. However, this design causes a compilation error when we try to define enqueue:

1//covariant type T occurs in contravariant position in type T of value x
2def enqueue(x: T): Queue[T] = new Queue(leading, x :: trailing)

The error occurs because T is covariant, but it's being used in a contravariant position — as a method parameter. In Scala, you cannot use a covariant type as an input parameter type, since that could break type safety.

To fix this, we introduce a lower bound type parameter U >: T. This allows the method to accept any supertype of T while returning a Queue[U]:

1def enqueue[U >: T](x: U): Queue[U] = new Queue(leading, x :: trailing)

Let's see this in action:

1val empty = new Queue[String](Nil, Nil)
2val stringQ: Queue[String] = empty.enqueue("The answer")
3val anyQ: Queue[Any] = stringQ.enqueue(42)

When we enqueue an Int into a Queue[String], the compiler infers the nearest common supertype of String and Int, which is Any. As a result, the returned type becomes Queue[Any]. This preserves type safety while allowing flexibility in enqueue operations.

In short, lower bounds let you define methods that remain compatible with covariance by relaxing type constraints — a key concept when designing reusable and type-safe generic collections.