Back to Blog
Python

Using TypeVar for Generic Functions in Python

python typevar: Learn how Python's TypeVar preserves type information in generic functions and classes, including constraints, bounds, and variance rules.

typinggenericstype-hintsmypystatic-analysis
A Python TypeVar acting as a generic placeholder that preserves type information between function inputs and outputs.

What TypeVar Solves

When you write a function that works with multiple types, the obvious approach is to annotate parameters with Any. That works, but it discards type information: the caller gets Any back and loses static checking on the return value. python typevar exists to fix that problem by letting you write a generic function once while keeping the connection between input and output types visible to type checkers.

Consider a function that returns the first element of a list:

from typing import Any def first(items: list[Any]) -> Any: return items[0]

Every call site now returns Any. If the caller passes list[int], the type checker cannot confirm that result is an int, so attribute access and arithmetic on it go unchecked. Replacing Any with a TypeVar restores that checking.

Declaring a TypeVar

A TypeVar is a placeholder type. You declare it once, then reference it in the signature of one or more functions or classes:

from typing import TypeVar T = TypeVar("T") def first(items: list[T]) -> T: return items[0]

The string passed to TypeVar should match the variable name. It is used in error messages and when the type is displayed by tooling, so keeping them in sync avoids confusion.

When first is called with a list[int], the type checker binds T to int and infers that the return value is int. The same function works for list[str], list[float], or any other element type without the caller seeing Any anywhere in the result.

You can use the same TypeVar in multiple positions to express relationships between parameters and return values:

def pair(left: T, right: T) -> tuple[T, T]: return (left, right)

Here the checker enforces that both arguments have the same type. Passing an int and a str produces a type error even though the function would run fine at runtime.

Constraining a TypeVar to Specific Types

A plain TypeVar accepts any type. If the body of the function only makes sense for a small set of types, pass them as positional arguments:

Number = TypeVar("Number", int, float) def scale(value: Number, factor: float) -> Number: return value * factor

Number can only be bound to int or float. Passing a str is rejected at static analysis time. This is different from a union type in an annotation: value: int | float does not let you express "the return type is the same as the input type." The TypeVar keeps that relationship while restricting the allowed types.

Constraints are checked statically only. At runtime, scale("a", 2.0) still executes and raises TypeError from the multiplication. The constraint is a contract for type checkers, not a runtime guard.

Using bound to Restrict to a Base Class

When the function needs any type that is a subclass of a given class, use bound instead of constraints:

from typing import TypeVar class Animal: def make_sound(self) -> str: return "generic sound" T = TypeVar("T", bound=Animal) def describe(animal: T) -> T: print(animal.make_sound()) return animal

bound=Animal means T can be any type that is Animal or inherits from it, such as a Dog or Cat class. The difference from constraints is important: constraints limit T to an exact list of types, while bound accepts the bound class and every subclass.

A common use is preserving the concrete type when a function returns an instance of the same class it received. Without the TypeVar, describe would have to return Animal, forcing callers to cast back to the concrete subclass. With bound=Animal, calling describe(dog) returns Dog in the type checker's view.

Covariance, Contravariance, and Invariance

When a TypeVar is used inside a generic class, you need to decide how subtyping behaves. By default a TypeVar is invariant: list[int] is not a subtype of list[float] even though int is a subtype of float. The type checker treats the element type as part of the class identity.

Declaring a TypeVar with covariant=True allows a generic class to preserve subtyping in the same direction:

from typing import TypeVar, Generic T_co = TypeVar("T_co", covariant=True) class Box(Generic[T_co]): def __init__(self, value: T_co) -> None: self._value = value def get(self) -> T_co: return self._value

A Box[int] is then assignable to Box[float] because int is assignable to float. This mirrors how Sequence[int] is treated as a Sequence[float] by the standard library.

Contravariance is the reverse direction, used when a type only appears in input positions, as with callable parameters:

from typing import TypeVar, Generic, Callable T_contra = TypeVar("T_contra", contravariant=True) class Handler(Generic[T_contra]): def __init__(self, func: Callable[[T_contra], None]) -> None: self._func = func def run(self, value: T_contra) -> None: self._func(value)

The rule to remember: if the type appears only in return positions, covariance is safe; if it appears only in parameter positions, contravariance is safe; if it appears in both, the type must stay invariant. The type checker enforces this, so a covariant=True TypeVar used in a parameter position produces an error.

What Happens at Runtime

A TypeVar is a real object created when the module is imported, but it does not participate in runtime type checking. The annotations on a function are evaluated at definition time and stored in __annotations__, then ignored during normal execution unless code explicitly inspects them. With from __future__ import annotations, the annotations are stored as strings instead, but the behavior is the same: no runtime enforcement.

This means a generic function has no runtime cost from the TypeVar itself. Calling first([1, 2, 3]) does not check that T was bound to int. The binding exists only in the type checker's analysis. If you need runtime validation, you must add it explicitly, for example with isinstance checks or a validation library. A TypeVar will never raise TypeError on its own.

One practical consequence is that a generic function cannot branch on the bound type:

def process(value: T) -> str: if T is int: # This does not work return "integer" return "other"

T is a TypeVar object, not the actual argument type, so this comparison is always false. To dispatch on the runtime type, use isinstance(value, int) or overloads instead.

Common Mistakes and How to Avoid Them

The most frequent mistake is using Any when a TypeVar would preserve information. If the relationship between input and output types matters, Any silently disables checking on every call site.

A second mistake is confusing constraints with bound. TypeVar("T", int, str) restricts T to exactly those two types. TypeVar("T", bound=int) allows int and every subclass. For numeric types the difference rarely matters, but for class hierarchies it changes which call sites are accepted.

A third issue is naming. The string argument and the variable name should match. T = TypeVar("U") works, but error messages and tooling will show U where the code reads T, which makes debugging type errors harder.

Finally, remember that TypeVar is for static analysis. If the goal is runtime validation, use a different mechanism. Mixing the two expectations leads to code that passes type checks but fails in production with data that was never validated.

Variadic Generics with TypeVarTuple

For functions that accept a variable number of arguments while preserving each argument's type, TypeVarTuple extends the same idea:

from typing import TypeVarTuple Ts = TypeVarTuple("Ts") def wrap_all(*values: *Ts) -> tuple[*Ts]: return values

Calling wrap_all(1, "a", 3.0) is inferred as tuple[int, str, float] rather than tuple[Any, ...]. This is useful for decorators, argument forwarding, and APIs that build tuples from heterogeneous inputs. TypeVarTuple requires Python 3.11 or later, or a recent version of the typing_extensions package on older interpreters.

The same variance rules apply to each element position, and the runtime behavior matches a plain TypeVar: no enforcement, no overhead, purely static information.

python typevar: Practical Usage and Code Examples | RYUSLOG DEV