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 of Container[Animal]
  • Contravariant (-T): Container[Animal] is a subtype of Container[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.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.