Generics, variance and bounds

Scala · Advanced · 16 min

Variance determines when a container of a subtype can be used as a container of a supertype.

Classes, traits and methods take type parameters: class Box[A](value: A), def first[A](xs: List[A]): Option[A]. The compiler infers them at call sites.

Variance describes how subtyping of the parameter relates to subtyping of the generic type, given Cat <: Animal:

  • Covariant +A: List[Cat] <: List[Animal]. Right for immutable producers: List, Option, Vector, Either.
  • Contravariant -A: Printer[Animal] <: Printer[Cat]. Right for consumers: a function that accepts any Animal can be used where one accepting Cat is needed. Function1[-A, +B] is contravariant in input and covariant in output.
  • Invariant (no annotation): no relationship. Required for mutable containers: Array[Cat] must not be an Array[Animal], or you could put a Dog into it.

The compiler checks variance positions: a covariant type can't appear as a method parameter. The fix is a lower bound: def prepend[B >: A](b: B): List[B] widens the element type as needed, which is how List lets you add an Animal to a List[Cat].

Upper bounds A <: Comparable[A] restrict a parameter to subtypes. Prefer type classes (using Ordering[A]) for capabilities like ordering, because they work for types you don't control.

Type parameters are erased at runtime on the JVM: List[Int] and List[String] are the same class, so case xs: List[Int] can't be checked. A ClassTag or TypeTest recovers some information when needed.

Example

sealed trait Animal { def name: String }
case class Cat(name: String) extends Animal
case class Dog(name: String) extends Animal

final case class Box[+A](value: A):
  def map[B](f: A => B): Box[B] = Box(f(value))
  def orElse[B >: A](other: B): List[B] = List(value, other)  // lower bound

trait Printer[-A]:
  def print(a: A): String

val animalPrinter: Printer[Animal] = a => s"animal ${a.name}"
val catPrinter: Printer[Cat] = animalPrinter      // OK: contravariance

val catBox: Box[Cat] = Box(Cat("Tom"))
val animalBox: Box[Animal] = catBox               // OK: covariance
println(animalBox.map(_.name))                    // Box(Tom)
println(catBox.orElse(Dog("Rex")))                // List(Cat(Tom), Dog(Rex))
println(catPrinter.print(Cat("Kit")))             // animal Kit

def maxOf[A <: Comparable[A]](a: A, b: A): A = if a.compareTo(b) >= 0 then a else b
println(maxOf("pear", "apple"))                   // pear
// val arr: Array[Animal] = Array[Cat]()          // error: Array is invariant

Remember

  • +A covariant for producers, -A contravariant for consumers, invariant for mutable
  • Use a lower bound B >: A to accept wider types in covariant classes
  • Upper bounds A <: T restrict; type classes are often better
  • Generics are erased at runtime

Watch out for

  • Matching case xs: List[Int] and expecting the element type to be checked; it's erased.

Check yourself

  1. Why is Array invariant while List is covariant?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: docs.scala-lang.org/scala3/book/types-variance.html