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)): AcceptsAnimalor any subclass ofAnimal(Dog,Cat). - Constrained Type (
TypeVar("T", str, bytes)): RestrictsTstrictly tostrorbytes(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!