Sometimes a function or class works for any type, but you want to express "the input and output are the same type." def first(items: list[T]) -> T says "a list of T returns a T." The T is a type variable — a placeholder that the type checker resolves per-call.
TypeVar — the workhorse
T = TypeVar("T") creates a type variable. Use it in function signatures or as a class parameter (with Generic[T] in the bases). Bound variables — T = TypeVar("T", bound=Comparable) — restrict T to subtypes of Comparable. Constrained — T = TypeVar("T", int, str) — restrict T to one of a specific set.
Generic classes — Stack[int] vs Stack[str]
A class that's generic over its element type. class Stack(Generic[T]) with methods using T in their signatures. Stack[int]() creates a stack of ints; Stack[str]() creates one of strings. The type checker infers element type and verifies operations.
Self — "the same class as me"
Methods that return their own class often want to say "return Self." Pre-3.11, you'd use TypeVar("T", bound="MyClass") as a workaround. Python 3.11 added typing.Self for this exact case — def chain(self) -> Self: means "returns whatever class actually called this." Useful for fluent interfaces and factory methods on subclasses.
ParamSpec — preserving full call signatures
For decorators that wrap functions, you want to say "the wrapper takes the same arguments as the wrapped function." P = ParamSpec("P") captures the entire parameter list (positional + keyword). Decorator hint: def wrap(fn: Callable[P, R]) -> Callable[P, R]. Tools like FastAPI use this heavily.
Code
TypeVar — generic functions·python
from typing import TypeVar
T = TypeVar("T")
def first(items: list[T]) -> T:
return items[0]
x: int = first([1, 2, 3]) # T = int
y: str = first(["a", "b", "c"]) # T = str
# Bound TypeVar — T must be a subtype of float
from typing import TypeVar
N = TypeVar("N", bound=float)
def average(values: list[N]) -> float:
return sum(values) / len(values)
print(average([1, 2, 3])) # ints work — int is subtype of float-ish
print(average([1.5, 2.5])) # floats work
Self (3.11+) — return-type for fluent interfaces·python
from typing import Self
class QueryBuilder:
def __init__(self):
self.filters: list[str] = []
def where(self, condition: str) -> Self:
self.filters.append(condition)
return self
def order_by(self, field: str) -> Self:
self.filters.append(f"ORDER BY {field}")
return self
# Subclass — methods return SubQuery, not QueryBuilder
class SubQuery(QueryBuilder):
def with_extra(self) -> Self:
self.filters.append("EXTRA")
return self
sq = SubQuery()
result: SubQuery = sq.where("x = 1").order_by("id").with_extra()
# Self ensures the return type is SubQuery throughout the chain
ParamSpec — decorators with full signatures·python
from typing import Callable, ParamSpec, TypeVar
import functools
import time
P = ParamSpec("P")
R = TypeVar("R")
def timed(fn: Callable[P, R]) -> Callable[P, R]:
@functools.wraps(fn)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
start = time.perf_counter()
result = fn(*args, **kwargs)
print(f"{fn.__name__}: {(time.perf_counter()-start)*1000:.2f}ms")
return result
return wrapper
@timed
def add(a: int, b: int) -> int:
return a + b
# The type checker knows: add still has signature (int, int) -> int
# even after the decorator. ParamSpec preserves it.
Implement a generic class Cache(Generic[K, V]) with get(key: K) -> V | None and set(key: K, value: V) -> None. Use two TypeVars. Then create Cache[str, int]() and demonstrate type checker would catch passing wrong types. Then implement a decorator log_calls using ParamSpec that preserves the wrapped function's full signature. Verify (mentally) that mypy retains the original signature.
Progress
Progress is local-only — sign in to sync across devices.