Scala - Variance (Covariant, Contravariant, Invariant)
Variance controls how generic type parameters behave in inheritance hierarchies. Consider a simple class hierarchy:
Key Insights
- Variance defines how subtyping relationships between types affect subtyping relationships between generic containers - covariant (
+T) preserves the relationship, contravariant (-T) reverses it, and invariant (no annotation) ignores it - Covariance works for producer types (output positions), contravariance for consumer types (input positions), following the principle that producers can return more specific types while consumers can accept more general types
- The Liskov Substitution Principle underlies variance: covariant types can substitute with subtypes, contravariant types can substitute with supertypes, ensuring type safety without runtime casts
Understanding Variance Fundamentals
Variance controls how generic type parameters behave in inheritance hierarchies. Consider a simple class hierarchy:
class Animal {
def makeSound(): String = "Some sound"
}
class Dog extends Animal {
override def makeSound(): String = "Woof"
}
class Cat extends Animal {
override def makeSound(): String = "Meow"
}
While Dog is a subtype of Animal, is List[Dog] a subtype of List[Animal]? The answer depends on variance. Scala provides three variance annotations:
- Covariant (
+T):Container[Dog]is a subtype ofContainer[Animal] - Contravariant (
-T):Container[Animal]is a subtype ofContainer[Dog] - Invariant (no annotation): No subtype relationship exists
Covariance: Producers and Output Types
Covariance is appropriate when a type parameter appears only in output positions. Immutable collections like List are covariant:
sealed trait ImmutableList[+A] {
def head: A
def tail: ImmutableList[A]
def isEmpty: Boolean
}
case object Empty extends ImmutableList[Nothing] {
def head: Nothing = throw new NoSuchElementException
def tail: ImmutableList[Nothing] = throw new NoSuchElementException
def isEmpty: Boolean = true
}
case class Cons[+A](h: A, t: ImmutableList[A]) extends ImmutableList[A] {
def head: A = h
def tail: ImmutableList[A] = t
def isEmpty: Boolean = false
}
This allows substitution:
val dogs: ImmutableList[Dog] = Cons(new Dog, Empty)
val animals: ImmutableList[Animal] = dogs // Valid because of covariance
def printAnimals(animals: ImmutableList[Animal]): Unit = {
if (!animals.isEmpty) {
println(animals.head.makeSound())
printAnimals(animals.tail)
}
}
printAnimals(dogs) // Works seamlessly
Covariance breaks when you try to use the type parameter in input positions:
// This won't compile
trait MutableList[+A] {
def add(element: A): Unit // Error: covariant type A in contravariant position
}
The workaround uses lower bounds:
sealed trait SafeList[+A] {
def prepend[B >: A](element: B): SafeList[B]
}
case object EmptyList extends SafeList[Nothing] {
def prepend[B](element: B): SafeList[B] = ListNode(element, EmptyList)
}
case class ListNode[+A](head: A, tail: SafeList[A]) extends SafeList[A] {
def prepend[B >: A](element: B): SafeList[B] = ListNode(element, this)
}
val dogList: SafeList[Dog] = ListNode(new Dog, EmptyList)
val animalList: SafeList[Animal] = dogList.prepend(new Cat) // Returns SafeList[Animal]
Contravariance: Consumers and Input Types
Contravariance reverses the subtype relationship and applies to types that consume values. Function parameters are contravariant:
trait Processor[-A] {
def process(item: A): Unit
}
class AnimalProcessor extends Processor[Animal] {
def process(item: Animal): Unit = {
println(s"Processing animal: ${item.makeSound()}")
}
}
class DogProcessor extends Processor[Dog] {
def process(item: Dog): Unit = {
println(s"Processing dog: ${item.makeSound()}")
}
}
With contravariance, a Processor[Animal] can substitute for a Processor[Dog]:
def processDogs(dogs: List[Dog], processor: Processor[Dog]): Unit = {
dogs.foreach(processor.process)
}
val animalProcessor = new AnimalProcessor
val dogs = List(new Dog, new Dog)
// Valid: Processor[Animal] substitutes for Processor[Dog]
processDogs(dogs, animalProcessor)
This makes sense: if you need something that processes dogs, an animal processor works because it can handle any animal, including dogs. The reverse wouldn’t be safe:
def processAnimals(animals: List[Animal], processor: Processor[Animal]): Unit = {
animals.foreach(processor.process)
}
val dogProcessor = new DogProcessor
val animals: List[Animal] = List(new Dog, new Cat)
// This would fail if allowed - DogProcessor can't handle cats
// processAnimals(animals, dogProcessor) // Type error
Function Variance in Practice
Scala’s Function1 trait demonstrates both contravariance and covariance:
trait Function1[-T, +R] {
def apply(arg: T): R
}
Input type T is contravariant, output type R is covariant:
val animalToString: Animal => String = (a: Animal) => a.makeSound()
val dogToAnimal: Dog => Animal = (d: Dog) => d
// Valid substitutions
val dogToString: Dog => String = animalToString // Contravariant input
val dogToAny: Dog => Any = dogToString // Covariant output
def transformDog(dog: Dog, f: Dog => String): String = f(dog)
// Can pass Animal => String where Dog => String is expected
transformDog(new Dog, animalToString)
Invariance: Mutable Types
Mutable containers must be invariant because they both consume and produce values:
class MutableBox[A](private var value: A) {
def get: A = value // Output position
def set(newValue: A): Unit = { value = newValue } // Input position
}
val dogBox = new MutableBox[Dog](new Dog)
// val animalBox: MutableBox[Animal] = dogBox // Doesn't compile
// If it did compile, this would be unsafe:
// animalBox.set(new Cat) // Would put a Cat in a Box[Dog]!
Arrays in Scala are invariant for the same reason, despite being covariant in Java (which leads to runtime errors):
val dogs: Array[Dog] = Array(new Dog)
// val animals: Array[Animal] = dogs // Compile error in Scala
// In Java, this compiles but fails at runtime:
// Animal[] animals = dogs;
// animals[0] = new Cat(); // ArrayStoreException
Variance Bounds and Practical Patterns
Upper and lower bounds provide flexibility:
class Container[+A] {
// Lower bound allows adding supertypes
def add[B >: A](item: B): Container[B] = ???
}
trait Comparable[A] {
// Upper bound ensures type safety
def compareTo[B <: A](other: B): Int
}
Real-world example with ordering:
case class PriorityQueue[+A](private val items: List[(A, Int)]) {
def enqueue[B >: A](item: B, priority: Int): PriorityQueue[B] = {
val newItems = ((item, priority) :: items).sortBy(_._2)
PriorityQueue(newItems)
}
def dequeue: (A, PriorityQueue[A]) = items match {
case Nil => throw new NoSuchElementException
case (item, _) :: rest => (item, PriorityQueue(rest))
}
}
val dogQueue: PriorityQueue[Dog] = PriorityQueue(List((new Dog, 1)))
val animalQueue: PriorityQueue[Animal] = dogQueue.enqueue(new Cat, 2)
Variance in Type Classes
Type classes often use invariant type parameters for flexibility:
trait Serializer[A] {
def serialize(value: A): String
def deserialize(str: String): A
}
implicit val animalSerializer: Serializer[Animal] = new Serializer[Animal] {
def serialize(value: Animal): String = s"Animal:${value.makeSound()}"
def deserialize(str: String): Animal = new Animal
}
def saveToDatabase[A](value: A)(implicit serializer: Serializer[A]): Unit = {
val serialized = serializer.serialize(value)
println(s"Saving: $serialized")
}
saveToDatabase(new Animal) // Works with exact type match
Variance annotations guide the compiler’s type checking and document your API’s intent. Use covariance for immutable producers, contravariance for consumers, and invariance for mutable types or bidirectional operations. Understanding these patterns prevents runtime errors and enables more flexible, type-safe code.