Python Generic Type Hints: A Practical Guide
python generic type hints: Learn how to use Python generic type hints with TypeVar and Generic to write reusable, type-safe code that works across static type checkers.
python generic type hints requires a clear understanding of the core syntax, runtime behavior, and practical implementation patterns demonstrated in the examples below.
When you write a function that returns a list of integers, the type checker knows the list contains integers. But if you want a function that returns a list of whatever type it receives, you need generic type hints. Python's typing module provides TypeVar and Generic to express these reusable patterns without sacrificing type safety.
The Problem: Losing Type Information in Generic Containers
Consider a function that returns the first element of a list. Without generics, you might write:
def first(items: list) -> object: return items[0]
The return type is object, which tells the caller nothing about the actual element type. If you pass a list of int, the type checker cannot infer that the result is an int. This forces manual casts or defeats static analysis.
A better approach uses a type variable to preserve the relationship between the input and output types. This is exactly what python generic type hints enable.
Basic Generic Syntax with TypeVar
To create a generic function, define a TypeVar and use it in the signature:
from typing import TypeVar T = TypeVar('T') def first(items: list[T]) -> T: return items[0]
Now when you call first([1, 2, 3]), the type checker infers T as int and the return type is int. The same function works for strings, custom classes, or any other type.
The TypeVar name is arbitrary but should be descriptive. Common conventions use single uppercase letters like T, K, V, but you can use longer names if they clarify intent.
Generic Functions and Type Variables
Type variables can appear in multiple positions. For example, a function that swaps two values:
def swap(a: T, b: T) -> tuple[T, T]: return b, a
Here both arguments must have the same type. If you pass an int and a str, the type checker will complain because T cannot be both int and str.
Type variables can also be used to enforce relationships between arguments and return values in more complex ways:
def map_list(items: list[T], func: Callable[[T], U]) -> list[U]: return [func(item) for item in items]
This uses two type variables, T and U, to express that the function transforms each element from type T to type U. The type checker can now verify that the callback matches the list element type.
Building Generic Classes
Generic classes let you create reusable data structures that work with any type. The Generic base class from typing is the standard way:
from typing import Generic, TypeVar 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()
When you instantiate Stack[int], the type checker treats push as accepting only int and pop as returning int. This catches type mismatches at development time.
Generic classes can also inherit from other generic classes. For example, a LimitedStack that inherits from Stack[T] and adds a capacity check:
class LimitedStack(Stack[T]): def __init__(self, capacity: int) -> None: super().__init__() self._capacity = capacity def push(self, item: T) -> None: if len(self._items) >= self._capacity: raise OverflowError("Stack is full") super().push(item)
The type variable T is inherited, so LimitedStack[int] works as expected.
Constraints and Bounds on Type Variables
Sometimes you need to restrict the types a type variable can represent. TypeVar accepts bound to limit to subclasses of a given type, or a tuple of concrete types.
Bounded TypeVar
from typing import TypeVar class Animal: def speak(self) -> str: ... TAnimal = TypeVar('TAnimal', bound=Animal) def make_speak(animal: TAnimal) -> TAnimal: animal.speak() return animal
Here TAnimal can be Animal or any subclass. The function can call speak because the bound guarantees it exists.
Constrained TypeVar
TNumber = TypeVar('TNumber', int, float) def add(a: TNumber, b: TNumber) -> TNumber: return a + b
TNumber can only be int or float. This is useful when you need to support numeric operations but not arbitrary types.
Bounds are more flexible than constraints because they allow any subclass, while constraints are a fixed set. Choose based on whether you want to allow future subclasses or restrict to a known set.
Variance: Covariance and Contravariance
Variance describes how type variables behave in inheritance relationships. By default, TypeVar is invariant, meaning Stack[int] is not a subtype of Stack[object] even though int is a subtype of object. This is the safe default because mutation could break type safety.
You can mark a type variable as covariant or contravariant using the covariant and contravariant keyword arguments:
T_co = TypeVar('T_co', covariant=True) T_contra = TypeVar('T_contra', contravariant=True)
Covariance is appropriate for read-only containers. For example, Sequence[T_co] is covariant because you only get elements out of it. List[T] is invariant because you can also put elements in.
Contravariance is appropriate for write-only or callable arguments. A function that accepts a Callable[[T_contra], None] can be used where a callable accepting a broader type is expected.
The following table summarizes the behavior:
| Variance | Meaning | Example |
|---|---|---|
| Invariant | No subtype relationship | List[T] |
| Covariant | Subtype relationship preserved | Sequence[T_co] |
| Contravariant | Subtype relationship reversed | Callable[[T_contra], None] |
Most of the time you do not need to specify variance. The typing module already provides covariant and contravariant variants for common collections. Only define custom variance when you are building your own generic classes that mimic these semantics.
Runtime Behavior and Type Hint Erasure
Type hints are not enforced at runtime. Python's interpreter ignores annotations and executes the code without checking types. This means generic type hints have zero runtime overhead for the operations they describe. The typing module itself is imported at module load time, but the cost is negligible compared to the benefits of static analysis.
One practical consequence is that you cannot rely on type hints to validate input. If you need runtime validation, use isinstance checks or a validation library. Generic type hints are for developers, static type checkers, and IDEs, not for the Python runtime.
Another consequence is that TypeVar and Generic are erased when the module is loaded. You cannot inspect the type arguments of a generic class at runtime. For example, Stack[int] and Stack[str] both become Stack at runtime. This is a deliberate design choice to keep Python fast and flexible.
Common Mistakes and How to Avoid Them
Using list Instead of List in Older Python Versions
Before Python 3.9, built-in containers like list and dict did not support subscripting for type hints. You had to use typing.List and typing.Dict. In Python 3.9+, list[int] works directly. If you support both, use from __future__ import annotations or stick to typing.List for compatibility.
Forgetting to Import Generic
When defining a generic class, you must inherit from Generic[T]. Forgetting this makes the class a plain class and type variables lose their meaning. The type checker will not raise an error immediately, but the annotations will not behave as expected.
Misusing TypeVar in Class Definitions
If you define a TypeVar inside a method and try to use it in the class body, it will not work. Type variables must be defined at module level or in a scope where they are accessible to all methods. Define them at module level for consistency.
Ignoring Variance in Custom Generic Classes
If you mark a type variable as covariant but then allow mutation through a method, the type checker will reject the code. For example:
T_co = TypeVar('T_co', covariant=True) class BadStack(Generic[T_co]): def push(self, item: T_co) -> None: # Type error ...
Covariance implies you can only read values, not write them. The type checker enforces this, so you cannot accidentally create an unsafe generic class.
When Generics Are Worth the Complexity
Generic type hints add a layer of abstraction. They are most valuable when you are building reusable libraries, data structures, or functions that operate on multiple types while preserving type safety. For a one-off script where the types are fixed, generics may be overkill and make the code harder to read.
Use generics when:
- You are writing a function that should work with any type, such as a utility that processes lists or dictionaries.
- You are designing a class that represents a container or a wrapper that should be type-aware.
- You need to enforce a relationship between input and output types that a simple
Anycannot express.
Avoid generics when the type relationships are simple and a concrete type annotation suffices. For example, a function that always returns a list[int] does not need a type variable.
Generics also integrate with modern type checkers like mypy, pyright, and pyre. They improve autocomplete and refactoring in IDEs. The initial cost of writing generic code is repaid when the code is reused across different types or maintained over time.
One final consideration: generic type hints are part of the public API of a library. If you expose a generic class, changing its variance or type parameters can break downstream users. Design the generic interface carefully and document the expected type behavior.