TLDR: Why Variance Matters #
When working with generic types like List[T], Option[T], or your own Container[T], variance becomes crucial.
If Dog extends Animal, what about List[Dog] and List[Animal]?
Jump to:
Variance #
Basic Setup #
Let’s start with a simple class hierarchy:
class Book
class Textbook(subject: String) extends Book
class Novel(title: String) extends Book
val mathBook = new Textbook("Mathematics")
val mysteryNovel = new Novel("The Great Mystery")
val aBook: Book = mathBook
// Textbook is a subtype of Book
classDiagram
Book <|-- Textbook : extends
Book <|-- Novel : extends
class Book
class Textbook {
+String subject
}
class Novel {
+String title
}
Subtype Relations Notation #
A <: Bmeans “A is a subtype of B” (A extends B)A >: Bmeans “A is a supertype of B” (B extends A)
If Textbook <: Book, what about Library[Textbook] vs Library[Book]?
The Three Types of Variance #
1. Covariance (+) #
Covariance means if A <: B, then Container[A] <: Container[B].
// Defining a covariant type
class Library[+T] // + makes T covariant
val someBooks: Library[Book] = new Library[Textbook]
// Library[Textbook] <: Library[Book] works!Covariance (+T) - Producers: “More Specific Outputs”
- Output values of type
T(likeList[+T],Option[+T]) Library[Textbook]can be used whereverLibrary[Book]is expected- Safe because every
Textbookis aBook
2. Invariance (no symbol) #
Invariance means no subtype relationship between parameterized types.
trait BookExchange[T] {
def trade(bookToGive: T): T
// both accepts AND returns T
}
// Not allowed: BookExchange[Textbook] as BookExchange[Book]
// Would break type safety if you traded a Novel for a TextbookWhy invariance is necessary: If we allowed BookExchange[Textbook] <: BookExchange[Book], you could:
- Pass it where
BookExchange[Book]is expected - Trade a
Novel(which is aBook) - Get back a
Novelwhere you expected aTextbook
3. Contravariance (-) #
Contravariance means if A <: B, then Container[B] <: Container[A] (flipped!).
// Example: A Librarian that can organize any Book
// can also organize Textbooks specifically
trait Librarian[-T] { // - makes T contravariant
def organize(book: T): Boolean
}
// Textbook <: Book, so Librarian[Book] <: Librarian[Textbook]
val myLibrarian: Librarian[Textbook] = new Librarian[Book] {
def organize(book: Book): Boolean = true
}
val organizingMathBook = myLibrarian.organize(mathBook)Contravariance (-T) - Consumers: “More General Inputs”
- Accept values of type
T(likeComparator[-T]) Librarian[Book]can be used whereverLibrarian[Textbook]is expected- Safe because it can handle any
Book(includingTextbook)
Variance Summary #
| Type | Notation | Relationship | Use Case |
|---|---|---|---|
| Covariance | [+A] |
If A <: B, then Container[A] <: Container[B] |
Producers (output values) |
| Contravariance | [-A] |
If A <: B, then Container[A] >: Container[B] |
Consumers (input values) |
| Invariance | [A] |
No relationship between containers | Both produce and consume |
Variance Positions #
Scala enforces rules about where variant types can appear to maintain type safety:
Issue Concepts #
Covariant Position Issues #
Types of val fields are in covariant positions because they can only be read (produce values). Since val fields return values, they must preserve the type relationship safely.
// ❌ This doesn't compile!
class Librarian[-T](val favoriteBook: T)
// Error: Contravariant type T occurs in covariant position
// Let's assume the above compiled (hypothetically)
// Here's why this breaks type safety:
val bookLibrarian: Librarian[Book] =
new Librarian[Book](new Novel("Holmes"))
val textbookLibrarian: Librarian[Textbook] = bookLibrarian
// contravariance allows this ↑
val book: Textbook = textbookLibrarian.favoriteBook
// ❌ Runtime error: Novel ≠ Textbook!The Problem: Contravariant types (-T) can’t be used in positions where values are returned (covariant positions like val fields). This would let you extract the wrong type, breaking type safety.
Contravariant Position Issues #
Types of var fields are in contravariant positions because they can be written to (accept values). Since var fields accept input through setters, they must handle the type relationship in the opposite direction.
// ❌ This doesn't compile!
class MutableLibrary[+T](var content: T)
// Error: Covariant type T occurs in contravariant position
// Let's assume the above compiled (hypothetically)
// Here's why this breaks type safety:
val textbookLibrary: MutableLibrary[Textbook] =
new MutableLibrary[Textbook](new Textbook("Math"))
val bookLibrary: MutableLibrary[Book] = textbookLibrary
// covariance allows this ↑
bookLibrary.content = new Novel("Mystery Story")
// ❌ Runtime error: Novel assigned where Textbook expected!The Problem: Covariant types (+T) can’t be used in positions where values are accepted (contravariant positions like var setters). This would allow unsafe mutations that violate type constraints.
Practical Examples #
Method Arguments (Contravariant Position) #
Method parameters accept input values, placing them in contravariant positions. Covariant types (+T) can’t appear here as it would break type safety.
// ❌ This doesn't compile!
class MyLibrary[+T] {
def add(book: T): MyLibrary[T] = new MyLibrary[T]
// Error: Covariant type T occurs in contravariant position
}
// ✅ Solution: Widen the type
class MyLibrary[+A] {
def add[B >: A](book: B): MyLibrary[B] = new MyLibrary[B]
// B is a supertype of A, so we can accept more general types
// This preserves covariance while allowing safe input
}Why this works: A MyLibrary[Textbook] can now accept any Book (including Novel), creating a MyLibrary[Book]. Since we’re widening to a more general type, covariance is preserved safely.
Method Return Types (Covariant Position) #
Method return types produce output values, placing them in covariant positions. Contravariant types (-T) can’t appear here as it would break type safety.
// ❌ This doesn't compile!
abstract class Librarian[-T] {
def recommendBook(): T
// Error: Contravariant type T occurs in covariant position
}
// ✅ Solution: Narrow the type argument
abstract class Librarian[-A] {
def recommendBook[B <: A](): B
// B is a subtype of A, so we return more specific types
// This preserves contravariance while allowing safe output
}Why this works: A Librarian[Book] can recommend a specific Textbook. Since we’re narrowing to a more specific type, the librarian can still handle any Book while returning something more precise.
Variance Position Summary #
| Variance Type | Can’t Appear In | Solution | Example |
|---|---|---|---|
Covariant (+T) |
Method parameters, var setters |
Use [B >: T] (supertype bound) |
def add[B >: A](item: B) |
Contravariant (-T) |
val fields, method return types |
Use [B <: T] (subtype bound) |
def get[B <: A](): B |