Python TypeVar and Generic Polymorphic Functions

Python's typing.TypeVar and generic types provide a powerful mechanism for writing reusable, type-safe code through parametric polymorphism. By allowing functions to accept and return dynamic types while preserving the exact type identity across arguments and return values, these tools eliminate code duplication without sacrificing static type analysis. This article explains the limitations of using loose types like Any, how TypeVar preserves type relationships, how to apply type constraints and bounds, and how modern Python syntax streamlines generic programming.

The Limitation of Any and object

When designing functions that handle multiple types, relying on object or typing.Any often breaks static type safety:

def get_first(items: list[object]) -> object:
    return items[0]

val = get_first(["apple", "banana"])
# Static type checkers view `val` as `object`, losing string-specific methods.

Using object or Any severs the relationship between the input type and the output type. Static analysis tools like mypy or integrated development environments (IDEs) can no longer verify whether subsequent operations on the returned value are valid.

How TypeVar Solves the Problem

TypeVar functions as a placeholder that captures and binds the concrete type passed into a function during a specific invocation. This enables true parametric polymorphism, ensuring that the returned type mirrors the input type precisely.

from typing import TypeVar

T = TypeVar("T")

def get_first(items: list[T]) -> T:
    return items[0]

val = get_first(["apple", "banana"])
# The type checker identifies `val` as `str`.

In this implementation, if a list[str] is passed, T resolves to str, and the function guarantees a str return type. If a list[int] is passed, T resolves to int.

Constraining Generic Types

Sometimes polymorphic functions should only work with a specific subset of types. TypeVar accommodates this through explicit type constraints or inheritance bounds.

Explicit Type Constraints

Pass multiple types as positional arguments to restrict TypeVar to an explicit set:

from typing import TypeVar

AnyStr = TypeVar("AnyStr", str, bytes)

def concat(a: AnyStr, b: AnyStr) -> AnyStr:
    return a + b

concat("hello ", "world")  # Valid: T is str
concat(b"hello ", b"world")  # Valid: T is bytes
# concat("hello ", b"world") # Rejected: Types do not match

Upper Bounds

Use the bound keyword argument to allow any type that is a subclass of a specified base class:

from typing import TypeVar

class Shape:
    def area(self) -> float:
        raise NotImplementedError

ShapeT = TypeVar("ShapeT", bound=Shape)

def print_area(shape: ShapeT) -> ShapeT:
    print(shape.area())
    return shape

The bound=Shape constraint ensures that any argument passed has an area method, while returning the specific subclass instead of degrading to the generic base Shape.

Modern Python 3.12+ Syntax

Python 3.12 introduced PEP 695, providing a cleaner, native syntax for defining generic functions without explicitly importing and declaring TypeVar:

def get_first[T](items: list[T]) -> T:
    return items[0]

def print_area[ShapeT: Shape](shape: ShapeT) -> ShapeT:
    print(shape.area())
    return shape

Under the hood, this syntax constructs a TypeVar scoped strictly to the function, improving readability while retaining the same polymorphic behavior.

Key Benefits of Generic Polymorphism