Advanced Python Type Hints with Generics

Learn advanced Python type hints: Generic classes, TypeVar with bounds and constraints, Protocol types, and practical patterns for type-safe code.

9 min read

Advanced type hints go beyond annotating function parameters with basic types. They let you express relationships between types using generics, define interfaces with Protocol, and write type-safe code that static checkers can verify without changing Python's runtime behaviour.

The standard library typing module was introduced in Python 3.5 and has evolved significantly. Python 3.9 let you write list[int] instead of List[int] without importing from typing, and Python 3.12 added new syntax for declaring generic classes and functions directly, such as class Stack[T]. This article focuses on the patterns that type checkers like mypy and pyright understand.

Type hints are optional at runtime. Python never enforces them. But for large codebases, libraries, and team projects, they catch bugs before code runs and serve as living documentation that cannot go stale.

The article on building a Python library with advanced features shows type hints like these used throughout a real public API.

TypeVar: making functions generic

A TypeVar is a placeholder that represents any type. It lets you write a function that preserves the relationship between argument types and return types.

Without generics, a function that returns the first element of a list would lose type information. The return type would be Any or object.

pythonpython
from typing import TypeVar
 
T = TypeVar("T")
 
def first(items: list[T]) -> T:
    return items[0]
 
num = first([1, 2, 3])
text = first(["a", "b", "c"])
print(num)
print(text)

The type checker infers that num is int and text is str. It knows that first called on a list of integers returns an integer.

texttext
1
a

You can constrain a TypeVar to a fixed set of allowed types. The function will only accept arguments matching those specific types, and the type checker rejects anything else at analysis time.

pythonpython
Number = TypeVar("Number", int, float)
 
def double(value: Number) -> Number:
    return value * 2
 
print(double(5))
print(double(3.5))

The type checker accepts int and float arguments and rejects any other type at analysis time. The return type is inferred to match the specific argument type passed at each individual call site.

texttext
10
7.0

Bounded TypeVars restrict to subtypes of a base class. This is useful when you need to call methods defined on the base class.

Generic classes

A Generic class is parameterized by one or more type variables. This lets you build reusable containers that preserve type information about their contents.

pythonpython
from typing import Generic, TypeVar
 
K = TypeVar("K")
V = TypeVar("V")
 
class Pair(Generic[K, V]):
    def __init__(self, key: K, value: V):
        self.key = key
        self.value = value
 
pair: Pair[str, int] = Pair("age", 30)
print(pair.key, pair.value)

The type checker knows that pair.key is a string and pair.value is an integer. Any mismatch between the type annotation and the actual values is flagged.

texttext
age 30

Generic classes work naturally with Python's simplified generics syntax in Python 3.12 and later. The Generic base class is still the underlying mechanism.

pythonpython
class Stack[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()
 
stack: Stack[int] = Stack()
stack.push(1)
stack.push(2)
print(stack.pop())

The Stack class works with any type. The type checker ensures you only push and pop values of the declared type.

texttext
2

Protocol: structural subtyping

A Protocol defines an interface that any class can satisfy by having the right methods. No inheritance is required. This matches Python's duck-typing philosophy while giving type checkers the information they need.

pythonpython
from typing import Protocol
 
class Drawable(Protocol):
    def draw(self) -> str: ...
 
class Circle:
    def draw(self) -> str:
        return "circle"
 
class Square:
    def draw(self) -> str:
        return "square"
 
def render(shape: Drawable) -> str:
    return shape.draw()
 
print(render(Circle()))
print(render(Square()))

Neither Circle nor Square inherits from Drawable. The type checker accepts both because they have the required draw method with the correct signature.

texttext
circle
square

Protocols are ideal for library APIs where you want to accept any object with the right methods. They allow callers to pass their own types without depending on your class hierarchy.

Practical patterns for library code

When writing a library, combine generics and protocols to create flexible, type-safe APIs. Use a TypeVar for the parts that vary across call sites and a Protocol for the interface your library requires.

pythonpython
from typing import Protocol, TypeVar
 
class Comparable(Protocol):
    def __lt__(self, other) -> bool: ...
 
T = TypeVar("T", bound=Comparable)
 
def maximum(a: T, b: T) -> T:
    return a if a > b else b
 
print(maximum(10, 20))
print(maximum("apple", "banana"))

The function works with any type that supports comparison. The type checker ensures both arguments are the same type and that the type implements comparison.

texttext
20
banana

The article on advanced Python mistakes to avoid covers common pitfalls with type hints, including over-annotation, runtime type checking confusion, and when to keep things simple rather than adding generics.

Rune AI

Rune AI

Key Insights

  • TypeVar creates a type variable that can be used across a function or class signature.
  • Bounded TypeVars restrict to subtypes; constrained TypeVars restrict to a fixed set of types.
  • Generic classes parameterize over type variables for reusable, type-safe containers.
  • Protocol defines structural interfaces: any class with matching methods satisfies the protocol.
  • Type hints are optional at runtime but invaluable for tooling, documentation, and large codebases.
RunePowered by Rune AI

Frequently Asked Questions

What is a TypeVar in Python?

A TypeVar is a placeholder for a type that can vary. It lets you define generic functions and classes that work with multiple types while preserving type safety. You can constrain a TypeVar to specific types, set bounds, or leave it unconstrained. Type checkers use TypeVars to infer the concrete types at each call site and verify that the same type is used consistently.

What is the difference between Protocol and ABC in Python?

Protocol defines an interface through structural subtyping: any class that has the right methods satisfies the protocol, regardless of inheritance. ABC (Abstract Base Class) requires explicit inheritance and registration. Protocols enable duck typing with static checking. Use Protocol when you want type checkers to verify that passed objects have the required methods without forcing them to inherit from your base class.

Conclusion

Advanced type hints turn Python's dynamic nature into a statically verifiable contract without sacrificing flexibility. TypeVars with constraints and bounds let you express precise relationships between types. Generic classes provide reusable, type-safe containers. Protocols enable structural subtyping that matches Python's duck-typing philosophy. Used together, these tools make large Python codebases safer and more maintainable.