TypeVar creates a placeholder that links the types in a signature together, so a function annotated with it tells the type checker that what comes out is the same type as what went in.
That relationship is the entire point, and it is the thing plain annotations cannot express. list[Any] -> Any says nothing useful. list[T] -> T says the return type is whatever the list contained, and the checker can act on it.
Table of contents
- The problem it solves
- Bound type variables
- Constrained type variables, and why they are different
- Generic classes
- Python 3.12 makes most of this unnecessary
- When it is worth the effort
- How this fits the rest of the stack
- FAQ
The problem it solves
from typing import Any
def first(items: list[Any]) -> Any:
return items[0]
name = first(["alice", "bob"])
name.upper() # fine
name.nonsense() # also fine, as far as the checker is concerned
Any disables checking. The function returns something and the checker stops asking questions, which means every downstream use of that value is unverified.
from typing import TypeVar, Sequence
T = TypeVar("T")
def first(items: Sequence[T]) -> T:
return items[0]
name = first(["alice", "bob"]) # checker knows: str
name.upper() # fine
name.nonsense() # error: str has no attribute nonsense
count = first([1, 2, 3]) # checker knows: int
Same runtime behaviour, entirely different static picture. T is resolved separately at each call site from the argument that was actually passed.
One convention worth following: annotate parameters with Sequence, Iterable, or Mapping rather than list and dict where you only read from them. A function taking Sequence[T] accepts lists, tuples, and strings. One taking list[T] rejects a tuple for no reason.
Bound type variables
A bare T accepts anything, so inside the function you can only do things valid for every possible type — which is almost nothing. bound= sets an upper limit.
from typing import TypeVar
class Model:
id: int
def save(self) -> None: ...
M = TypeVar("M", bound=Model)
def save_all(items: list[M]) -> list[M]:
for item in items:
item.save() # allowed: every M is at least a Model
return items
M can be Model or any subclass, and inside the function the checker knows it has Model’s interface. The return type preserves the specific subclass, so save_all(list_of_users) returns list[User], not list[Model]. That preservation is what you lose by annotating with Model directly.
Protocols work as bounds too, which is often the better fit because it does not demand inheritance:
from typing import Protocol, TypeVar
class Comparable(Protocol):
def __lt__(self, other) -> bool: ...
C = TypeVar("C", bound=Comparable)
def largest(items: list[C]) -> C:
return max(items)
Anything implementing __lt__ satisfies that — no base class, no registration. This is usually what you want for behavioural requirements.
Constrained type variables, and why they are different
Passing several types to TypeVar creates a constrained variable, which is not the same thing as a bound and behaves in a way that catches people out.
S = TypeVar("S", str, bytes) # constrained: exactly str or exactly bytes
def join_all(items: list[S], sep: S) -> S:
return sep.join(items)
The difference: a constrained variable resolves to exactly one of the listed types, never to a subclass and never to a union. If you subclass str, the checker resolves S to str rather than your subclass.
B = TypeVar("B", bound=str) # str or any subclass
S = TypeVar("S", str, bytes) # exactly str, or exactly bytes
Constrained variables are the right tool when the types share an operation but no useful common ancestor — str and bytes both have join, and their nearest common base is object. That is genuinely rare. If you find yourself listing three or more types, a bound or a Protocol is almost certainly what you meant.
Generic classes
TypeVar alone is not enough for a class; you inherit from Generic to declare the parameter.
from typing import Generic, TypeVar
T = TypeVar("T")
class Queue(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(0)
jobs: Queue[str] = Queue()
jobs.push("resize-image")
jobs.push(42) # error: expected str
value = jobs.pop() # checker knows: str
The parameter is bound once when the class is parameterised, and every method sees the same T. That is what makes Queue[str] reject an int at the push call rather than at some later point where the error is harder to trace.
Python 3.12 makes most of this unnecessary
PEP 695 introduced inline type parameter syntax, and it removes the module-level declarations entirely.
# 3.12 and later
def first[T](items: Sequence[T]) -> T:
return items[0]
class Queue[T]:
def __init__(self) -> None:
self._items: list[T] = []
def save_all[M: Model](items: list[M]) -> list[M]:
...
def join_all[S: (str, bytes)](items: list[S], sep: S) -> S:
...
Read the syntax as: [T] is a plain variable, [M: Model] is a bound, [S: (str, bytes)] is a constraint. No TypeVar import, no Generic base class, and the scope is exactly the function or class it belongs to.
That scoping is a real improvement over the old form. A module-level T = TypeVar("T") looks shared between every function that references it, and it is not — each function gets its own independent binding. The inline syntax removes the false impression.
Use the new syntax if you are on 3.12 or later and do not need to support older versions. Otherwise the TypeVar form is not going away and remains entirely correct.
When it is worth the effort
Generics pay off in code other people call: shared utilities, repository and service layers, anything that wraps or transforms values while preserving their type. A Repository[User] that returns User rather than Any catches mistakes at the call site, in the editor, before the code runs.
They pay off less in application code that operates on concrete types. def send_invoice(invoice: Invoice) -> None needs no type variables and adding one makes it harder to read.
Worth being honest about the limits: none of this exists at runtime. Annotations are not enforced by Python — a Queue[str] will accept an int quite happily if nobody ran a type checker. The value is entirely in mypy or pyright running in CI. Type hints that nothing checks are documentation, and documentation drifts.
So the useful move is putting the checker in the pipeline. A build that runs mypy on every push turns annotations into a gate rather than a suggestion. On RunxBuild that is part of the build step, with the deploy log showing exactly which check failed and on which commit — so a type error stops a deploy instead of becoming a runtime error somebody finds later.
How this fits the rest of the stack
Catching a bug in CI is cheaper than catching it in production, though neither line appears on an invoice. What does is the runtime, the database, the storage, and the bandwidth — the RunxBuild hosting calculator puts those together so the monthly total is visible before you commit.
Useful related references:
- Python Not Equal: != vs is not, and Why the Difference Bites
- Python Integer Division: Why // Floors and Why -7 // 2 Is -4
- Python for Websites: Where It Fits and Where It Does Not
- Python services on RunxBuild
FAQ
What is TypeVar used for in Python?
It creates a placeholder that links types within a signature, so a checker knows the return type relates to the argument type. Annotating a function as Sequence[T] -> T tells the checker the result is whatever the sequence contained, where Any would disable checking entirely.
What is the difference between bound and constrained TypeVars?
bound=X accepts X or any subclass and preserves the specific subclass in the result. TypeVar(S, str, bytes) is constrained and resolves to exactly one listed type, never a subclass or union. Constraints suit types sharing an operation but no useful common ancestor; bounds suit everything else.
Do I need Generic for a generic class?
Before Python 3.12, yes — TypeVar alone declares the variable but the class must inherit from Generic[T] to be parameterised. From 3.12 the inline syntax class Queue[T] handles both and the Generic base is no longer needed.
What is the Python 3.12 generics syntax?
PEP 695 inline type parameters: def first[T](items: Sequence[T]) -> T. Bounds are written [M: Model] and constraints [S: (str, bytes)]. It removes the TypeVar import and the Generic base class, and scopes the variable to the function or class that declares it.
Are Python type hints enforced at runtime?
No. Annotations are metadata and Python does not check them, so a Queue[str] will accept an int if nothing runs a type checker. The value comes entirely from running mypy or pyright, ideally in CI so a type error fails the build rather than surfacing in production.