Scala Generics, Type Bounds, and Variance

TT

Scala Generics, Type Bounds, and Variance

Scala's type system is one of its defining strengths. Generics, type bounds, and variance give you the tools to write reusable, type-safe APIs. This module builds from basic generics up to variance — a concept that separates Scala from most other languages.

Generic Classes

Generic classes are parameterized by one or more types:

scala
class Box[A](val value: A) {
  def map[B](f: A => B): Box[B] = new Box(f(value))

  override def toString: String = s"Box($value)"
}

val intBox = new Box(42)
val stringBox = intBox.map(_.toString)

println(intBox)     // Box(42)
println(stringBox)  // Box(42)

The type parameter A is a placeholder — when you create Box(42), Scala infers A = Int.

Generic Methods

Methods can also have type parameters:

scala
def identity[A](x: A): A = x

def swap[A, B](pair: (A, B)): (B, A) = (pair._2, pair._1)

println(identity(42))              // 42
println(swap(("hello", 123)))      // (123,hello)

Upper Type Bounds

An upper bound [A <: SomeType] restricts A to be SomeType or a subtype of it:

scala
class Animal(val name: String)
class Dog(name: String) extends Animal(name)
class Cat(name: String) extends Animal(name)

def printName[A <: Animal](animal: A): Unit = {
  println(animal.name)
}

printName(new Dog("Rex"))  // Rex
printName(new Cat("Luna")) // Luna
// printName(42)  // compile error — Int is not a subtype of Animal

Upper bounds are used when you need to call methods on the generic type that are defined in the bound class.

Lower Type Bounds

A lower bound [A >: SomeType] restricts A to be SomeType or a supertype of it. This is less common but essential for variance:

scala
def addToList[A >: Dog](dog: Dog, list: List[A]): List[A] = dog :: list

val dogs: List[Dog] = List(new Dog("Rex"))
val animals: List[Animal] = addToList(new Dog("Buddy"), dogs)
// The return type is List[Animal] because Animal is the common supertype

Context Bounds

A context bound [A : Ordering] is syntactic sugar for an implicit parameter:

scala
def max[A : Ordering](x: A, y: A): A = {
  val ord = implicitly[Ordering[A]]
  if (ord.compare(x, y) >= 0) x else y
}

println(max(3, 5))      // 5
println(max("a", "z"))  // z

This is equivalent to def max[A](x: A, y: A)(implicit ord: Ordering[A]): A.

Variance: The Core Concept

Variance determines how generic types relate when their type parameters are in a subtype relationship.

If Dog <: Animal, how does Box[Dog] relate to Box[Animal]?

There are three possible answers:

  • Invariant (default): Box[Dog] and Box[Animal] are unrelated
  • Covariant (+A): Box[Dog] <: Box[Animal] — a Box of Dogs is a Box of Animals
  • Contravariant (-A): Box[Animal] <: Box[Dog] — a Box of Animals is a Box of Dogs (for writing)

Covariance (+T)

Covariance is written as +T. A covariant container can be used where a container of a supertype is expected:

scala
sealed trait Shape
case class Circle(r: Double) extends Shape
case class Square(s: Double) extends Shape

class ReadOnlyBox[+A](val value: A)

val circleBox: ReadOnlyBox[Circle] = new ReadOnlyBox(Circle(5.0))
val shapeBox: ReadOnlyBox[Shape] = circleBox  // OK because ReadOnlyBox is covariant

println(shapeBox.value)  // Circle(5.0)

Covariance is safe for read-only (output) positions — you can always use a subtype where a supertype is expected when reading.

List[+A] in Scala is covariant — a List[Dog] can be used as a List[Animal].

Contravariance (-T)

Contravariance is written as -T. A contravariant container can be used where a container of a subtype is expected:

scala
class Writer[-A] {
  def write(value: A): Unit = println(s"Writing: $value")
}

val animalWriter: Writer[Animal] = new Writer[Animal]
val dogWriter: Writer[Dog] = animalWriter  // OK because Writer is contravariant

dogWriter.write(new Dog("Rex"))  // Writing: Rex

Contravariance is safe for write-only (input) positions. A writer that can handle any Animal can certainly handle Dog. The type parameter is in "consuming" position.

Function1[-A, +B] is both contravariant in input and covariant in output — this is the correct variance for functions.

Variance Rules

The compiler enforces these rules:

PositionCovariant (+T)Contravariant (-T)
Return type (output)✅ Allowed❌ Not allowed
Method parameter (input)❌ Not allowed✅ Allowed
Mutable field❌ Not allowed❌ Not allowed

This is why immutable collections like List[+A] can be covariant but mutable collections like Array[A] must be invariant.

Frequently Asked Questions

Q: When should I use +T vs -T vs invariant? Use +T (covariant) for containers that only produce values of type T — read-only data structures, results, outputs. Use -T (contravariant) for containers that only consume values of type T — handlers, writers, comparators. Use invariant (default) for containers that both read and write, like mutable collections. The mnemonic: "producers extend, consumers super" (PECS from Java).

Q: Why does Scala's List use +A but Array uses plain A? List is immutable — you can never modify a List[Dog] in a way that would break a reference to it as List[Animal]. Array is mutable — if you could write val animals: Array[Animal] = Array(new Dog()) and then animals(0) = new Cat(), you'd corrupt the underlying Array[Dog]. The compiler prevents this by making Array invariant.

Q: What is a type bound used for in practice? Upper bounds (<: T) are used when a method needs to call methods from the bound type on its parameter. For example, def sort[A <: Comparable[A]](list: List[A]) can call compareTo on elements. Lower bounds (>: T) are used in covariant containers to allow methods that append items — List[+A] uses def ::[B >: A](x: B): List[B] to stay type-safe.


Part of Scala Mastery Course — Module 9 of 22.