Technology  /  Scala

๐Ÿ”ด Scala 21 guides ยท updated 2026

The JVM's hybrid OOP/functional language โ€” immutability, pattern matching, and the type system that powers Spark and Akka, from first principles to production.

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 Int
firstElement(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 Animal
class Cat extends Animal
class 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 Animal

The 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.

YES โ€” List is declared covariant: class List[+T]

Generally NO โ€” would be unsafe

Cat extends Animal

(ordinary subtyping)

Does List[Cat] extend List[Animal]?

List[Cat] IS-A List[Animal]

Does Box[Cat] extend Box[Animal],

for a MUTABLE Box?

Box[Cat] is NOT a Box[Animal]

if Box allows writing


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 contravariant
catPrinter.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 parameterVarianceMnemonic
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 Animal
def 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 Animal
feedAll(List("not an animal")) // Error: String is not a subtype of Animal
// Lower bound: T must BE or be a SUPERTYPE of some specific type
class 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 type

Nothing โ€” 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

SymptomLikely 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 expectedThe generic type parameter is invariant and the method wasnโ€™t written with an upper bound (T <: SomeType) allowing subtypes through
Confusing error mentioning NothingOften 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 expectedThis 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 method
def identity[T](x: T): T = x
// Generic CLASS โ€” the type parameter is fixed once, at instantiation, for every method on that instance
class 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.