Scala Generics, Type Bounds, and Variance
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:
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:
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:
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 AnimalUpper 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:
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 supertypeContext Bounds
A context bound [A : Ordering] is syntactic sugar for an implicit parameter:
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")) // zThis 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]andBox[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:
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:
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: RexContravariance 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:
| Position | Covariant (+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.
