Generics and TypeVar
Before we begin, predict the output of this snippet:
from typing import TypeVar, Generic, List, Sequence
T = TypeVar("T")
class Stack(Generic[T]):
def __init__(self) -> None:
self._items: list[T] = []
def push(self, item: T) -> None:
self._items.append(item)
def pop(self) -> T:
return self._items.pop()
s: Stack[int] = Stack()
s.push(1)
s.push("hello") # What does mypy say here?
def first(items: list[int]) -> int:
return items[0]
vals: list[bool] = [True, False]
first(vals) # What does mypy say here? Think carefully.
The first error is obvious -- "hello" is not an int. The second call is accepted by mypy with no error. Why? Because bool is a subclass of int, and list is invariant in its type parameter. Yet list[bool] passes where list[int] is expected because mypy treats bool as compatible with int everywhere (it is a literal subclass). This subtlety -- the intersection of variance, inheritance, and practical type checking -- is exactly what this lesson dissects.
What You Will Learn
- How
TypeVarcreates reusable type parameters for functions and classes - The difference between bound, constrained, and free type variables
- How to build generic classes with
Generic[T] - Covariance, contravariance, and invariance -- and why they matter
- TypeVar naming conventions used in production codebases
- Real-world generic patterns from FastAPI and SQLAlchemy
Prerequisites
- Comfortable with Python type hints (
int,str,list[int],dict[str, int],Optional,Union) - OOP fundamentals: inheritance, abstract base classes
- Familiarity with dataclasses and basic Pydantic models
- Understanding of the
typingmodule basics from the Intermediate course
Part 1 -- The Problem Generics Solve
Without generics, you face a choice: type safety or reusability. Consider a function that returns the first element of a collection:
def first_int(items: list[int]) -> int:
return items[0]
def first_str(items: list[str]) -> str:
return items[0]
def first_any(items: list) -> object:
return items[0]
The first two are type-safe but duplicated. The third is reusable but throws away type information -- the caller gets object and must cast. Generics eliminate this tradeoff entirely.
TypeVar: The Fundamental Building Block
from typing import TypeVar
T = TypeVar("T")
def first(items: list[T]) -> T:
return items[0]
# mypy infers:
x: int = first([1, 2, 3]) # T bound to int, returns int
y: str = first(["a", "b"]) # T bound to str, returns str
z: float = first([1.0, 2.0]) # T bound to float, returns float
A TypeVar is a placeholder that gets bound to a concrete type at each call site. The type checker sees first([1, 2, 3]) and infers T = int, so the return type becomes int.
The string argument to TypeVar("T") must match the variable name T. This is not enforced at runtime, but mypy and pyright will flag mismatches.
How TypeVar Binding Works
Type variable binding follows scoping rules:
T = TypeVar("T")
def pair(a: T, b: T) -> tuple[T, T]:
return (a, b)
pair(1, 2) # OK: T = int
pair("a", "b") # OK: T = str
pair(1, "b") # ERROR: T cannot be both int and str
Within a single function call, every occurrence of the same TypeVar must resolve to the same type. This is a constraint, not a union.
T = TypeVar("T")
U = TypeVar("U")
def transform(a: T, b: U) -> tuple[T, U]:
return (a, b)
transform(1, "hello") # OK: T = int, U = str
When you need independent type parameters, use separate TypeVar instances.
Part 2 -- Bound vs Constrained TypeVars
Bound TypeVar
A bound TypeVar accepts the bound type and any of its subclasses:
from typing import TypeVar
class Animal:
def speak(self) -> str:
return "..."
class Dog(Animal):
def speak(self) -> str:
return "Woof"
class Cat(Animal):
def speak(self) -> str:
return "Meow"
A = TypeVar("A", bound=Animal)
def loudest(animals: list[A]) -> A:
return max(animals, key=lambda a: len(a.speak()))
dogs: list[Dog] = [Dog(), Dog()]
result = loudest(dogs) # result is Dog, not Animal
# The return type preserves the specific subtype
The bound parameter says "T must be Animal or a subclass of Animal." Crucially, the inferred type is the actual subtype passed, not the bound.
Constrained TypeVar
A constrained TypeVar accepts only the explicitly listed types:
from typing import TypeVar
StrOrBytes = TypeVar("StrOrBytes", str, bytes)
def concat(a: StrOrBytes, b: StrOrBytes) -> StrOrBytes:
return a + b
concat("hello", " world") # OK: StrOrBytes = str
concat(b"hello", b" world") # OK: StrOrBytes = bytes
concat("hello", b" world") # ERROR: inconsistent types
Bound and constrained TypeVars behave very differently. A bound TypeVar T(bound=int) accepts int and bool (subclass). A constrained TypeVar T(int, float) accepts exactly int or float -- not bool on its own, not any other subclass.
BoundT = TypeVar("BoundT", bound=int)
ConstrainedT = TypeVar("ConstrainedT", int, float)
def f(x: BoundT) -> BoundT: return x
def g(x: ConstrainedT) -> ConstrainedT: return x
f(True) # OK: bool is a subclass of int
g(True) # OK: bool is accepted as int (bool subclasses int)
g(1+2j) # ERROR: complex is not int or float
When to Use Which
| Scenario | Use |
|---|---|
| "Any type that implements interface X" | bound=X |
| "Exactly one of these specific types" | Constrained TypeVar(str, bytes) |
| "Literally any type" | Unbound TypeVar("T") |
Part 3 -- Generic Classes
Building a Generic Container
from typing import TypeVar, Generic, Iterator
T = TypeVar("T")
class Ring(Generic[T]):
"""A circular buffer with a fixed capacity."""
def __init__(self, capacity: int) -> None:
self._data: list[T | None] = [None] * capacity
self._capacity = capacity
self._write = 0
self._size = 0
def push(self, item: T) -> None:
self._data[self._write] = item
self._write = (self._write + 1) % self._capacity
self._size = min(self._size + 1, self._capacity)
def __iter__(self) -> Iterator[T]:
start = (self._write - self._size) % self._capacity
for i in range(self._size):
val = self._data[(start + i) % self._capacity]
assert val is not None
yield val
def __len__(self) -> int:
return self._size
ring: Ring[str] = Ring(3)
ring.push("a")
ring.push("b")
ring.push("c")
ring.push("d") # overwrites "a"
print(list(ring)) # ['b', 'c', 'd']
A class inheriting from Generic[T] becomes parameterizable. When you annotate ring: Ring[str], the type checker substitutes T = str throughout the class.
Multiple Type Parameters
from typing import TypeVar, Generic
K = TypeVar("K")
V = TypeVar("V")
class Pair(Generic[K, V]):
def __init__(self, key: K, value: V) -> None:
self.key = key
self.value = value
def swap(self) -> "Pair[V, K]":
return Pair(self.value, self.key)
p = Pair("age", 30) # Pair[str, int]
q = p.swap() # Pair[int, str]
print(q.key, q.value) # 30 age
Generic Classes with Inheritance
from typing import TypeVar, Generic
T = TypeVar("T")
class Repository(Generic[T]):
def __init__(self) -> None:
self._store: dict[int, T] = {}
self._next_id = 1
def add(self, item: T) -> int:
item_id = self._next_id
self._store[item_id] = item
self._next_id += 1
return item_id
def get(self, item_id: int) -> T | None:
return self._store.get(item_id)
class User:
def __init__(self, name: str) -> None:
self.name = name
class UserRepository(Repository[User]):
"""Concrete repository -- T is fixed to User."""
def find_by_name(self, name: str) -> User | None:
for user in self._store.values():
if user.name == name:
return user
return None
repo = UserRepository()
uid = repo.add(User("Alice"))
user = repo.get(uid) # type is User | None
repo.add("not a user") # ERROR: str is not User
When a subclass fixes the type parameter, all inherited methods use the concrete type.
Part 4 -- Covariance, Contravariance, and Invariance
This is where most engineers' mental models break. Let us build it from first principles.
The Liskov Substitution Question
If Dog is a subtype of Animal, is list[Dog] a subtype of list[Animal]?
class Animal:
pass
class Dog(Animal):
pass
def feed_animals(animals: list[Animal]) -> None:
animals.append(Animal()) # This is legal for list[Animal]
dogs: list[Dog] = [Dog(), Dog()]
feed_animals(dogs) # If allowed, dogs now contains a plain Animal!
# A Dog list would be corrupted with a non-Dog Animal
If list[Dog] were a subtype of list[Animal], we could pass dogs to feed_animals, which would insert a plain Animal into a list that should only contain Dog instances. This is a type safety violation.
The Three Variance Kinds
Invariant: Container[Dog] is not a subtype of Container[Animal], and Container[Animal] is not a subtype of Container[Dog]. Used when the container is both read from and written to. Example: list.
Covariant: Container[Dog] is a subtype of Container[Animal]. The container only produces values, never consumes them. Example: Sequence, frozenset, Mapping (for values).
Contravariant: Container[Animal] is a subtype of Container[Dog]. The container only consumes values, never produces them. Example: Callable parameter types.
Declaring Variance with TypeVar
from typing import TypeVar, Generic, Iterator
T_co = TypeVar("T_co", covariant=True)
T_contra = TypeVar("T_contra", contravariant=True)
class ReadOnlyBox(Generic[T_co]):
"""Covariant: only produces T values."""
def __init__(self, value: T_co) -> None:
self._value = value
def get(self) -> T_co:
return self._value
# def set(self, value: T_co) -> None: # ERROR if uncommented
# self._value = value # Can't use covariant T in input
class Sink(Generic[T_contra]):
"""Contravariant: only consumes T values."""
def __init__(self) -> None:
self._items: list[object] = []
def put(self, item: T_contra) -> None:
self._items.append(item)
# def get_last(self) -> T_contra: # ERROR if uncommented
# return self._items[-1] # Can't use contravariant T in output
Naming convention: Append _co for covariant TypeVars and _contra for contravariant ones. This is the convention used in typeshed (the stdlib type stubs).
Practical Variance: list vs Sequence vs MutableSequence
from typing import Sequence, MutableSequence
class Animal:
pass
class Dog(Animal):
def fetch(self) -> str:
return "ball"
# Sequence is covariant (read-only) -- this works:
def count_animals(animals: Sequence[Animal]) -> int:
return len(animals)
dogs: list[Dog] = [Dog(), Dog()]
count_animals(dogs) # OK! Sequence[Dog] is subtype of Sequence[Animal]
# list is invariant (read-write) -- this fails:
def modify_animals(animals: list[Animal]) -> None:
animals.append(Animal())
modify_animals(dogs) # ERROR: list[Dog] is not list[Animal]
Rule of thumb: If your function only reads from a collection, annotate with Sequence. If it modifies the collection, annotate with list or MutableSequence.
Callable Variance
Callable is contravariant in its parameter types and covariant in its return type:
from typing import Callable
class Animal:
pass
class Dog(Animal):
pass
# A function accepting Animal can be used where Dog-acceptor is expected
handler_animal: Callable[[Animal], None] = lambda a: None
handler_dog: Callable[[Dog], None] = handler_animal # OK: contravariant params
# A function returning Dog can be used where Animal-returner is expected
factory_dog: Callable[[], Dog] = lambda: Dog()
factory_animal: Callable[[], Animal] = factory_dog # OK: covariant return
This is intuitive once internalized: a handler that can process any animal can certainly process dogs. A factory that produces dogs certainly produces animals.
Part 5 -- Generic Functions in Depth
Identity-Preserving Functions
from typing import TypeVar, overload
T = TypeVar("T")
def identity(x: T) -> T:
return x
reveal_type(identity(42)) # int
reveal_type(identity("hello")) # str
Constraining Return Types Based on Input
from typing import TypeVar, Type
T = TypeVar("T")
def create_instance(cls: type[T], **kwargs: object) -> T:
return cls(**kwargs)
class Config:
def __init__(self, debug: bool = False) -> None:
self.debug = debug
cfg = create_instance(Config, debug=True) # type is Config, not object
The type[T] annotation means "the class object itself whose instances are of type T." This is essential for factory functions.
Multiple TypeVars in Practice
from typing import TypeVar, Callable
T = TypeVar("T")
U = TypeVar("U")
V = TypeVar("V")
def pipe(value: T, f: Callable[[T], U], g: Callable[[U], V]) -> V:
return g(f(value))
result = pipe(
"42",
int, # str -> int
lambda x: x > 0 # int -> bool
)
# result is bool, value is True
Part 6 -- TypeVar Naming Conventions
Production codebases follow consistent naming. Here are the conventions from typeshed, FastAPI, and SQLAlchemy:
| Convention | Meaning | Example |
|---|---|---|
T | General type | TypeVar("T") |
T_co | Covariant type | TypeVar("T_co", covariant=True) |
T_contra | Contravariant type | TypeVar("T_contra", contravariant=True) |
KT, VT | Key type, Value type | Mapping/dict generics |
RT | Return type | Callable return type |
_T | Module-private type var | Internal library use |
| Descriptive name | Domain-specific | SessionT, ModelT, EventT |
Prefer descriptive names in application code. Reserve single-letter names for library code and utility functions. ModelT = TypeVar("ModelT", bound=BaseModel) is far more readable than T = TypeVar("T", bound=BaseModel) when your module has multiple TypeVars.
Part 7 -- Real-World Patterns
Pattern: FastAPI Dependency Injection Typing
FastAPI's Depends uses generics to preserve the return type of dependency functions:
from typing import TypeVar, Callable, Annotated
from dataclasses import dataclass
T = TypeVar("T")
@dataclass
class User:
id: int
name: str
@dataclass
class DBSession:
url: str
def get_db() -> DBSession:
return DBSession(url="postgresql://localhost/app")
def get_current_user() -> User:
return User(id=1, name="Alice")
# In FastAPI, Depends preserves the type:
# db: Annotated[DBSession, Depends(get_db)]
# user: Annotated[User, Depends(get_current_user)]
# The endpoint parameter `db` is typed as DBSession, not Any
Pattern: SQLAlchemy Generic Repository
from typing import TypeVar, Generic, Type, Sequence
T = TypeVar("T")
class Base:
"""Simulated SQLAlchemy declarative base."""
id: int
class GenericRepository(Generic[T]):
def __init__(self, model_class: type[T]) -> None:
self._model_class = model_class
self._store: dict[int, T] = {}
def get_by_id(self, entity_id: int) -> T | None:
return self._store.get(entity_id)
def get_all(self) -> Sequence[T]:
return list(self._store.values())
def save(self, entity: T) -> T:
# In real code, this would flush to the DB
return entity
class UserModel(Base):
name: str
email: str
class CourseModel(Base):
title: str
price: float
# Concrete repositories with full type safety:
class UserRepo(GenericRepository[UserModel]):
def find_by_email(self, email: str) -> UserModel | None:
for u in self._store.values():
if u.email == email:
return u
return None
class CourseRepo(GenericRepository[CourseModel]):
def find_by_price_range(
self, min_price: float, max_price: float
) -> Sequence[CourseModel]:
return [
c for c in self._store.values()
if min_price <= c.price <= max_price
]
Pattern: Generic Result Type
from typing import TypeVar, Generic
from dataclasses import dataclass
T = TypeVar("T")
@dataclass
class Success(Generic[T]):
value: T
@dataclass
class Failure:
error: str
code: int
Result = Success[T] | Failure
def parse_int(s: str) -> Result[int]:
try:
return Success(int(s))
except ValueError:
return Failure(error=f"Cannot parse '{s}' as int", code=400)
result = parse_int("42")
match result:
case Success(value=v):
print(f"Got {v}") # v is int
case Failure(error=e):
print(f"Error: {e}")
Key Takeaways
- TypeVar creates reusable type placeholders that get bound to concrete types at each usage site
- Bound TypeVars (
bound=X) accept X and all subclasses; constrained TypeVars (X, Y) accept only the listed types - Generic classes inherit from
Generic[T]to parameterize their type; subclasses can fix or propagate the parameter - Invariance (default) means
Container[Sub]is NOT a subtype ofContainer[Super]-- use this for mutable containers - Covariance means
Container[Sub]IS a subtype ofContainer[Super]-- use this for read-only/producer containers - Contravariance means
Container[Super]IS a subtype ofContainer[Sub]-- use this for consumer/write-only containers - Annotate with
Sequence(covariant) instead oflist(invariant) when your function only reads from a collection - Use descriptive TypeVar names in application code; reserve
T,U,Vfor generic library utilities
Graded Practice Challenges
Level 1 -- Predict the Type Checker Output
Question 1: What does mypy report for this code?
from typing import TypeVar
T = TypeVar("T", int, str)
def double(x: T) -> T:
return x * 2
result = double(3.14)
Answer
mypy reports an error: Value of type variable "T" of "double" cannot be "float". The TypeVar is constrained to int and str only. float is not one of the allowed types. If you wanted to accept float, you would need TypeVar("T", int, str, float) or TypeVar("T", bound=float).
Question 2: Does this code pass mypy?
from typing import Sequence
class Animal:
pass
class Dog(Animal):
pass
def count(animals: Sequence[Animal]) -> int:
return len(animals)
dogs: tuple[Dog, ...] = (Dog(), Dog())
count(dogs)
Answer
Yes, this passes mypy. tuple[Dog, ...] is a Sequence[Dog], and Sequence is covariant, so Sequence[Dog] is a subtype of Sequence[Animal]. Both the tuple-to-Sequence compatibility and the Dog-to-Animal covariance work together.
Question 3: What is the type of result?
from typing import TypeVar, Generic
T = TypeVar("T")
class Box(Generic[T]):
def __init__(self, value: T) -> None:
self.value = value
def map(self, f: "Callable[[T], U]") -> "Box[U]":
return Box(f(self.value))
from typing import Callable
U = TypeVar("U")
b = Box(42).map(str)
Answer
The type of b is Box[str]. The map method takes a Callable[[T], U] where T = int (from Box(42)) and f = str (the str constructor, which is Callable[[int], str]), so U = str and the return is Box[str].
Level 2 -- Debug and Fix
The following generic cache is rejected by mypy. Identify all type errors and fix them.
from typing import TypeVar, Generic, Dict, Optional, Callable
T = TypeVar("T", covariant=True)
class Cache(Generic[T]):
def __init__(self) -> None:
self._store: Dict[str, T] = {}
def get(self, key: str) -> Optional[T]:
return self._store.get(key)
def put(self, key: str, value: T) -> None:
self._store[key] = value
def get_or_compute(self, key: str, factory: Callable[[], T]) -> T:
if key not in self._store:
self._store[key] = factory()
return self._store[key]
cache: Cache[int] = Cache()
cache.put("x", 42)
Answer
The problem is that T is declared as covariant, but Cache uses T in input positions (put, get_or_compute's factory, and dict assignment). Covariant TypeVars cannot appear in method parameters.
Fix: Remove the covariant=True since Cache both reads and writes values of type T. It should be invariant (the default):
T = TypeVar("T") # invariant -- no covariant=True
This is the only change needed. The class legitimately uses T in both input and output positions, so invariance is the correct variance.
Level 3 -- Design Challenge
Design a generic Pipeline class that chains transformations:
# Desired usage:
pipeline = (
Pipeline.of(str) # Pipeline that starts from str
.then(str.upper) # str -> str
.then(lambda s: len(s)) # str -> int
.then(lambda n: n > 5) # int -> bool
)
result: bool = pipeline.run("hello world") # True
Requirements:
- Full type safety -- mypy should infer all intermediate and final types
- The
runmethod should accept the input type and return the output type - The
thenmethod should return a new Pipeline with an updated output type - Support at least 5 chained
.then()calls
Hint
You need two TypeVars: one for the pipeline's input type and one for its current output type. The then method introduces a third TypeVar for the new output type and returns Pipeline[In, NewOut].
from typing import TypeVar, Generic, Callable
In = TypeVar("In")
Out = TypeVar("Out")
NewOut = TypeVar("NewOut")
class Pipeline(Generic[In, Out]):
def __init__(self, fn: Callable[[In], Out]) -> None:
self._fn = fn
@staticmethod
def of(input_type: type[In]) -> "Pipeline[In, In]":
return Pipeline(lambda x: x)
def then(self, fn: Callable[[Out], NewOut]) -> "Pipeline[In, NewOut]":
prev = self._fn
return Pipeline(lambda x: fn(prev(x)))
def run(self, value: In) -> Out:
return self._fn(value)
What's Next
In the next lesson, Protocol and Structural Subtyping, we explore how typing.Protocol brings type-safe duck typing to Python -- enabling you to define interfaces based on what objects do rather than what they inherit from.
