TIL: Covariant vs Contravariant Types

TIL: Covariant vs Contravariant Types

When working with type systems in programming languages like TypeScript, Scala, Java, or even Python (via type hints), you'll encounter the concepts of covariance and contravariance. These terms describe how the subtype relationships of generic types behave when you substitute their type parameters. Understanding them can help you design safer, more expressive APIs.


What is Variance?

Imagine a generic type Box[T] and types Cat and Animal where Cat is a subtype of Animal (Cat <: Animal). Variance answers:

What is the relationship between Box[Cat] and Box[Animal]?

There are three possibilities:

Relationship Meaning Name
Box[Cat] <: Box[Animal] Subtyping preserved Covariant
Box[Animal] <: Box[Cat] Subtyping reversed Contravariant
No relationship Subtyping not allowed in either direction Invariant

Covariant Types

A generic type is covariant in T if it maintains the same direction of subtyping:

If Cat <: Animal, then Box[Cat] <: Box[Animal]

Use this when your type only produces values of T. Think of read-only containers:

class ImmutableBox<+T> {
  get(): T
}

Here, it's safe to use an ImmutableBox<Cat> where an ImmutableBox<Animal> is expected because values are only being read.


Contravariant Types

A type is contravariant in T if it reverses the direction of subtyping:

If Cat <: Animal, then Printer[Animal] <: Printer[Cat]

Use this when your type only consumes values of T. For example, function inputs:

class Printer[-T] {
  print(value: T): void
}

A Printer[Animal] can be used wherever a Printer[Cat] is needed—it can handle anything a Printer[Cat] would.


Invariant Types

Most types are invariant by default:

class Box(Generic[T]):
    def get(self) -> T: ...
    def set(self, value: T): ...

Since Box[T] both reads and writes T, neither covariance nor contravariance is safe.


When to Use What?

Variance Use Case
Covariant Your type produces T (read-only)
Contravariant Your type consumes T (inputs, callbacks)
Invariant Your type both produces and consumes T

Python Type Hints Example

Python supports variance via typing.TypeVar:

from typing import TypeVar, Generic

T_co = TypeVar('T_co', covariant=True)
T_contra = TypeVar('T_contra', contravariant=True)

class Producer(Generic[T_co]):
    def get(self) -> T_co: ...

class Consumer(Generic[T_contra]):
    def consume(self, item: T_contra) -> None: ...

This allows you to encode variance into your Python type hints, even if not enforced at runtime.


Understanding variance helps clarify type relationships in your code, especially when working with generics, callbacks, and collections. Reach for covariance when you're returning data, contravariance when you're taking it in, and invariance when you're doing both.