Skip to main content

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 TypeVar creates 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 typing module 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.

note

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
danger

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

ScenarioUse
"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
tip

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:

ConventionMeaningExample
TGeneral typeTypeVar("T")
T_coCovariant typeTypeVar("T_co", covariant=True)
T_contraContravariant typeTypeVar("T_contra", contravariant=True)
KT, VTKey type, Value typeMapping/dict generics
RTReturn typeCallable return type
_TModule-private type varInternal library use
Descriptive nameDomain-specificSessionT, ModelT, EventT
tip

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 of Container[Super] -- use this for mutable containers
  • Covariance means Container[Sub] IS a subtype of Container[Super] -- use this for read-only/producer containers
  • Contravariance means Container[Super] IS a subtype of Container[Sub] -- use this for consumer/write-only containers
  • Annotate with Sequence (covariant) instead of list (invariant) when your function only reads from a collection
  • Use descriptive TypeVar names in application code; reserve T, U, V for 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:

  1. Full type safety -- mypy should infer all intermediate and final types
  2. The run method should accept the input type and return the output type
  3. The then method should return a new Pipeline with an updated output type
  4. 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.

© 2026 EngineersOfAI. All rights reserved.