Generics, TypeVars & Subtyping Variance

Writing type-safe reusable abstractions requires Generics (Generic[T]), constrained bounds (TypeVar(bound=...)), and Variance Principles (Covariance, Contravariance, Invariance). Understanding variance dictates how subtyping relationships between container types (e.g. Sequence[Dog] vs Sequence[Animal]) map to subtyping relationships between their underlying type arguments.

This chapter details TypeVar generic parameters, upper bounds vs constrained types, and the 3 Variance rules: Covariance (co=True), Contravariance (contra=True), and Invariance.


1. Generic Parameterization (TypeVar & Generic[T])

A Generic Class allows defining reusable algorithms and containers parameterized by abstract type variables:

from typing import TypeVar, Generic

# Define type variable
T = TypeVar("T")

class Stack(Generic[T]):
    def __init__(self):
        self._items: list[T] = []

    def push(self, item: T) -> None:
        self._items.append(item)

    def pop(self) -> T:
        return self._items.pop()

Type Variable Constraints:

  • Upper Bound (TypeVar("T", bound=Animal)): Accepts Animal or any subclass of Animal (Dog, Cat).
  • Constrained Type (TypeVar("T", str, bytes)): Restricts T strictly to str or bytes (no subclasses or other types allowed).

2. The 3 Variance Rules (Covariance, Contravariance, Invariance)

Variance answers a critical question: If Dog is a subclass of Animal (Dog <: Animal), what is the subtype relationship between Container[Dog] and Container[Animal]?

The 3 Subtyping Variance Rules:

1. COVARIANCE (co=True) - "Producers of T":
   Subtype direction is PRESERVED!
   Dog <: Animal  ===>  Sequence[Dog] <: Sequence[Animal]
   (Read-Only Data Collections: Sequence, Iterable, Mapping)

2. CONTRAVARIANCE (contra=True) - "Consumers of T":
   Subtype direction is REVERSED!
   Dog <: Animal  ===>  Callable[[Animal], None] <: Callable[[Dog], None]
   (Write-Only Handlers / Sinks: Callables, Serializers)

3. INVARIANCE (Default) - "Producers AND Consumers of T":
   NO subtyping relationship exists between containers!
   Dog <: Animal  ===>  List[Dog] NOT compatible with List[Animal]
   (Read-Write Mutable Collections: list, dict, set)

3. Why Mutable Lists are Invariant (list[T])

Why can’t you pass a list[Dog] to a function expecting list[Animal]?

class Animal: pass
class Dog(Animal): pass
class Cat(Animal): pass

def add_animal(animals: list[Animal]):
    animals.append(Cat())  # Appends a Cat to the list!

dogs: list[Dog] = [Dog()]
add_animal(dogs)  # IF ALLOWED: 'dogs' list now contains a Cat! BREAKS TYPE SAFETY!

Because list is a Read-Write Mutable Container, appending Cat() into dogs would corrupt the list type! Thus, mutable collections MUST BE INVARIANT.


4. Read-Only Containers are Covariant (Sequence[T])

Because Sequence[T] is Read-Only (you cannot append() or mutate elements), reading a Dog from a Sequence[Dog] safely fulfills the contract of reading an Animal from a Sequence[Animal]. Thus, read-only containers are Covariant:

from typing import Sequence, TypeVar

# T_co is COVARIANT (Producer-only)
T_co = TypeVar("T_co", bound=Animal, covariant=True)

def process_animals(animals: Sequence[Animal]):
    for a in animals:
        print(a)

dogs: list[Dog] = [Dog()]
process_animals(dogs)  # VALID! Sequence[T] is Covariant!
Display Options
Appearance
Text Size
100%