Generics and Variance in Scala: Covariance and Contravariance Explained
List[Cat] can be used anywhere a List[Animal] is expected โ but a hypothetical mutable Box[Cat] generally cannot be used where a Box[Animal] is expected, even though Cat is an Animal in both cases. This isnโt an inconsistency; itโs variance, a real, important part of Scalaโs type system that determines whether a generic typeโs own subtyping relationship follows, ignores, or reverses its type parameterโs subtyping relationship. Getting this right prevents a specific, serious category of runtime type error that would otherwise slip past the compiler.
Generic Classes and Functions โ A Quick Refresher
class Box[T](val value: T) { def get: T = value}
val intBox = new Box[Int](42)val stringBox = new Box[String]("hello")
def firstElement[T](list: List[T]): T = list.head
firstElement(List(1, 2, 3)) // 1 โ T inferred as IntfirstElement(List("a", "b")) // "a" โ T inferred as String[T] declares a type parameter โ the same generics mechanism covered in the TypeScript seriesโ generics guide, expressed in Scalaโs square-bracket syntax (Scala uses [T], not TypeScript/Javaโs <T>). Box[T] and firstElement[T] both work across any concrete type substituted for T, checked consistently by the compiler at each call site.
The Question Variance Actually Answers
class Animalclass Cat extends Animalclass Dog extends Animal
val cat: Cat = new Cat()val animal: Animal = cat // fine โ a Cat IS-A Animal, ordinary subtyping
val catList: List[Cat] = List(new Cat())val animalList: List[Animal] = catList // ALSO fine โ but why? List[Cat] isn't literally an AnimalThe second assignment works because List is declared covariant in its type parameter โ but nothing about generics automatically grants this. Variance is a deliberate annotation the typeโs author chooses, and different generic types make different choices depending on how they use their type parameter internally.
Covariance (+T) โ โA List of Subtypes IS-A List of the Supertypeโ
class ReadOnlyBox[+T](val value: T) { def get: T = value}
val catBox: ReadOnlyBox[Cat] = new ReadOnlyBox(new Cat())val animalBox: ReadOnlyBox[Animal] = catBox // fine โ ReadOnlyBox is covariant+T marks a type parameter as covariant โ meaning ReadOnlyBox[Cat] is treated as a subtype of ReadOnlyBox[Animal], mirroring Catโs own relationship to Animal. This is safe specifically because ReadOnlyBox only ever produces (returns) values of type T โ it never accepts a T as input anywhere. Since every operation on animalBox only ever reads an Animal out of it, and every actual value inside is a genuine Cat (which is an Animal), nothing can go wrong.
// Why covariance breaks down if T is also accepted as INPUT (a mutable box)class MutableBox[+T](private var value: T) { def get: T = value def set(newValue: T): Unit = { value = newValue } // Error: covariant type T occurs in contravariant position}The compiler refuses to compile this โ and for a genuinely good reason. If it were allowed:
val catBox: MutableBox[Cat] = new MutableBox(new Cat())val animalBox: MutableBox[Animal] = catBox // if this were legal...animalBox.set(new Dog()) // ...this would try to put a Dog into what's actually a Cat box!val cat: Cat = catBox.get // and this would return a Dog where a Cat was expected โ unsafe!This is precisely why Scalaโs compiler enforces variance rules at compile time rather than trusting the programmer โ allowing a mutable, covariant container would let a Dog masquerade as a Cat, a genuine type-safety hole. This is the exact reason List (immutable, read-only after construction) is covariant, while a mutable ArrayBuffer is not โ the difference is entirely about whether the type parameter is ever accepted as input after construction.
Contravariance (-T) โ The Reverse Relationship, for Consumers
trait Printer[-T] { def print(value: T): Unit}
val animalPrinter: Printer[Animal] = new Printer[Animal] { def print(value: Animal): Unit = println(s"Animal: $value")}
val catPrinter: Printer[Cat] = animalPrinter // fine โ Printer is contravariantcatPrinter.print(new Cat()) // uses the Animal printer, which works fine for a Cat too-T marks a type parameter contravariant โ meaning Printer[Animal] is treated as a subtype of Printer[Cat], the reverse of Animal/Catโs own relationship. This sounds backwards until you think about what a Printer[T] actually does: it consumes a T (accepts it as input, never returns one) โ a Printer[Animal] can print any Animal, which obviously includes printing a Cat specifically, so itโs perfectly safe to use anywhere a Printer[Cat] was needed. The relationship flips because the type parameterโs role flipped from โproducedโ (covariant-safe) to โconsumedโ (contravariant-safe).
// This is exactly the pattern behind Scala's own Function1[-Arg, +Return]trait Function1[-Arg, +Return] { def apply(arg: Arg): Return}Scalaโs built-in function type is contravariant in its argument and covariant in its return type โ a function accepting a wider input type and a function returning a narrower output type can both safely substitute for a more specific function type, which is exactly the parameter contravariance/return covariance pattern covered from the TypeScript side in that seriesโ functions guide.
The PECS-Style Rule for Remembering Which Is Which
| Role of the type parameter | Variance | Mnemonic |
|---|---|---|
| Only ever produced (returned, read) | Covariant (+T) | A Container[Cat] can substitute for Container[Animal] โ โproducesโ flows the same direction as subtyping |
| Only ever consumed (accepted as a parameter) | Contravariant (-T) | A Handler[Animal] can substitute for Handler[Cat] โ โconsumesโ flows opposite to subtyping |
| Both produced and consumed (like a mutable box) | Invariant (no +/-, the default) | No safe substitution either direction โ must match exactly |
This mirrors the PECS rule (โProducer Extends, Consumer Superโ) from Java generics wildcards, and the identical ? extends T / ? super T distinction covered in the TypeScript generics material โ the underlying safety reasoning is the same across all three languages, even though the syntax differs (Scala declares variance once, at the typeโs definition, rather than per-use-site the way Javaโs wildcards work).
Type Bounds โ Constraining What T Can Be
// Upper bound: T must BE or EXTEND Animaldef feedAll[T <: Animal](animals: List[T]): Unit = { animals.foreach(a => println(s"Feeding $a"))}
feedAll(List(new Cat(), new Cat())) // fine โ Cat is a subtype of AnimalfeedAll(List("not an animal")) // Error: String is not a subtype of Animal// Lower bound: T must BE or be a SUPERTYPE of some specific typeclass NodeList[+T](head: T, tail: NodeList[T]) { def prepend[U >: T](newHead: U): NodeList[U] = new NodeList(newHead, this)}T <: Animal (upper bound) restricts a type parameter to Animal or one of its subtypes โ the same constraint mechanism covered as T extends SomeType in the TypeScript generics guide. U >: T (lower bound) requires the opposite โ U must be T or one of its supertypes. Lower bounds show up specifically in covariant generic classes needing a method that would otherwise violate the covariant-position rule (like prepend above, adding a new element to an otherwise-covariant, immutable structure) โ allowing the result type to widen to a common supertype resolves the conflict safely.
A Realistic Covariant Hierarchy
sealed trait Container[+T] { def map[U](f: T => U): Container[U]}
case class Filled[+T](value: T) extends Container[T] { def map[U](f: T => U): Container[U] = Filled(f(value))}
case object Empty extends Container[Nothing] { def map[U](f: Nothing => U): Container[U] = Empty}
val filled: Container[Int] = Filled(42)val mapped: Container[String] = filled.map(_.toString)val empty: Container[Int] = Empty // valid โ Empty extends Container[Nothing], and Nothing is a subtype of EVERY typeNothing โ Scalaโs bottom type, a subtype of every other type โ is exactly why Empty (a singleton case object, needing no type parameter of its own) can still be treated as a Container[T] for any T: Container[Nothing] is, thanks to covariance, a subtype of Container[Int], Container[String], or anything else, since Nothing is a subtype of Int, String, and every other type simultaneously. This exact pattern โ a covariant container, a bottom-typed empty case โ is precisely how Scalaโs own Option[+T] (with None typed as Option[Nothing]) and List[+T] (with Nil typed as List[Nothing]) are actually implemented.
Common Pitfalls
| Symptom | Likely cause |
|---|---|
| โcovariant type T occurs in contravariant positionโ | A covariant (+T) type parameter is used as a method parameter type somewhere in the class โ either make the method generic with a lower bound (like prepend[U >: T]), or reconsider whether the type should be covariant at all |
| A generic method wonโt accept a list of a subtype where expected | The generic type parameter is invariant and the method wasnโt written with an upper bound (T <: SomeType) allowing subtypes through |
Confusing error mentioning Nothing | Often the result of an empty collection literal (List()) being inferred as List[Nothing] in a context expecting a more specific element type โ provide an explicit type annotation |
| A mutable collection canโt be passed where an immutable, covariant one is expected | This is by design โ mutable collections are invariant for the safety reasons this guide covers; convert explicitly (.toList) rather than expecting implicit substitutability |
Generic Methods vs Generic Classes โ A Quick Distinction
// Generic METHOD โ the type parameter is scoped to just this one methoddef identity[T](x: T): T = x
// Generic CLASS โ the type parameter is fixed once, at instantiation, for every method on that instanceclass Wrapper[T](val value: T) { def get: T = value def set(newValue: T): Wrapper[T] = new Wrapper(newValue)}A generic methodโs type parameter is inferred fresh at each call site (identity(5) and identity("hi") use entirely independent instantiations of T), while a generic classโs type parameter is fixed once for the entire lifetime of a given instance (new Wrapper[Int](5) is permanently a Wrapper[Int], never usable as a Wrapper[String]). This distinction matters when deciding whether a piece of generic logic belongs as a standalone function or as a method on a parameterized class โ if the โsameโ type parameter needs to stay consistent across several related operations on one value, a generic class is the right shape; if each call is independent, a generic method suffices.
Frequently Asked Questions
Why is List covariant but a mutable ArrayBuffer invariant? Because List is immutable โ its type parameter is only ever read, never written after construction, making covariance safe. ArrayBuffer allows both reading and writing (update), and allowing covariance there would let code insert an incompatible subtype through a widened reference, exactly the unsafe scenario the MutableBox example demonstrated.
Do I need to declare variance on every generic class I write? No โ the default, invariant (T with no +/-), is correct and safe for the common case where a type parameter is both read and written. Reach for +/- specifically when youโve designed a type thatโs genuinely read-only (covariant) or write-only/consuming (contravariant) with respect to that parameter, and you want the substitutability that variance grants.
Whatโs the practical difference between an upper bound (<:) and a type class constraint (like T: Ordering)? An upper bound requires T to be a subtype of a specific type via inheritance. A context bound (T: Ordering, covered in the type classes guide) requires an implicit instance of some type class to exist for T, without requiring T itself to inherit from anything โ a more flexible mechanism for โT must support this capabilityโ without forcing an inheritance relationship.
Is Nothing ever a type Iโd actually use directly in my own code? Rarely directly โ it mainly shows up as the natural return type of functions that never return normally (matching the never type from the TypeScript series), and as the type parameter for empty covariant containers like Nil/None, exactly as shown in the Empty example above.
Does variance affect runtime behavior or performance at all? No โ variance is purely a compile-time type-checking concept, fully resolved before your code runs, with zero runtime cost or behavior difference of its own.
Whatโs Next
Variance is what makes Scalaโs generic collection hierarchy substitutable the way youโd intuitively expect, while still catching real type-safety violations at compile time. Next: Implicits in Scala, covering the mechanism โ implicit parameters, conversions, and classes โ that predates and underlies much of Scala 2โs advanced type-system features, including type classes.