Home Tutorials Type Hints

Type Hints

Generics and TypeVar: Writing Type-Safe Reusable Code in Python

Pyford Notes July 6, 2026 8 min read
Key points
  • A plain type hint like list can't express 'this function returns the same type it was given' -- that needs a TypeVar.
  • A TypeVar used twice in one signature tells the type checker both occurrences must resolve to the same concrete type for a given call.
  • bound= restricts a TypeVar to subtypes of a given type, letting the checker assume methods from that bound are available.
  • Generic classes (Stack[int], Repository[User]) let a type checker track what a container holds through every method that returns it.

The problem plain type hints can't express

An ordinary type hint like def first(items: list) -> object is technically correct but nearly useless: it tells a type checker that the return value is some object, throwing away the fact that first([1, 2, 3]) obviously returns an int and first(["a", "b"]) obviously returns a str. Writing a separate overload for every possible element type isn't feasible. Generics solve exactly this: they let one signature say "whatever type goes in as the element type, that same type comes out."

TypeVar: one variable, consistent across a signature

typing.TypeVar creates a placeholder type. Using the same TypeVar in two places within one function signature tells the type checker to bind it to a specific type for that particular call, and hold every other occurrence to match:

from typing import TypeVar, Sequence

T = TypeVar("T")

def first(items: Sequence[T]) -> T:
    return items[0]

x = first([1, 2, 3])       # type checker infers T = int, so x: int
y = first(["a", "b"])       # T = str here, so y: str
z = first([1, "a"])          # T = int | str, the type checker unifies both

Nothing about this changes what happens at runtime — TypeVar and every other typing construct here are erased at runtime and exist purely for static analysis tools like mypy or pyright, and for editors doing autocomplete. Running the code above with plain CPython behaves identically with or without the annotations.

Bounded TypeVars: restricting which types qualify

An unbounded TypeVar accepts anything. A bound= argument restricts it to a specific type and its subtypes, which both documents intent and lets the type checker assume methods from the bound are safely callable inside the function body:

from typing import TypeVar
from numbers import Real

NumT = TypeVar("NumT", bound=Real)

def clamp(value: NumT, low: NumT, high: NumT) -> NumT:
    if value < low:
        return low
    if value > high:
        return high
    return value

clamp(5, 0, 10)      # fine: int is a Real
clamp(5.5, 0.0, 10.0)  # fine: float is a Real
# clamp("a", "b", "c")  # type checker flags this: str is not bound by Real

A related but distinct tool is a constrained TypeVar, TypeVar("T", int, str), which restricts the variable to exactly the listed types rather than any subtype of a single bound — useful when a function genuinely only makes sense for a fixed, small set of types.

Generic classes: a container parameterised by its contents

Generics aren't limited to functions. A class can be generic over one or more type parameters by inheriting from Generic[T], letting callers write Stack[int] or Stack[str] and have the type checker track what each specific stack instance actually holds:

from typing import TypeVar, Generic, List

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()

    def peek(self) -> T:
        return self._items[-1]

int_stack: Stack[int] = Stack()
int_stack.push(1)
int_stack.push(2)
value = int_stack.pop()   # type checker knows value: int, not just "some object"

# int_stack.push("oops")  # type checker flags this: str is not int

This pattern is exactly how the standard library's own generic containers — list[int], dict[str, User], Sequence[T] — are typed, and it's the same mechanism repository or data-access classes use so that Repository[User].get(id) is known to return a User, not an untyped object requiring a cast.

PEP 695: the simplified syntax in Python 3.12+

Python 3.12 introduced a lighter syntax via PEP 695 that removes the need to import and instantiate TypeVar explicitly for the common case:

# Python 3.12+
def first[T](items: list[T]) -> T:
    return items[0]

class Stack[T]:
    def __init__(self) -> None:
        self._items: list[T] = []

    def push(self, item: T) -> None:
        self._items.append(item)

The [T] right after the function or class name declares the type parameter inline, scoped to that definition, which removes the module-level TypeVar object and its naming clutter for straightforward cases. Code that needs to target Python versions before 3.12, or needs bounds and constraints in the older explicit form, still uses TypeVar directly as shown above; the two forms are equivalent to a type checker. Full details on both forms are in the typing module documentation.