Python generics & protocols, explained
Type hints get powerful when you make them reusable. Generics let one function or class work over many types without losing type information, and Protocols bring static checking to Python's duck typing. Together they let you type real, flexible code precisely.
The problem generics solve
Without generics, a function that returns one of its arguments either loses the type (using
Any) or must be written per-type. A type variable preserves the relationship between
input and output.
from typing import TypeVar
T = TypeVar("T")
def first(items: list[T]) -> T: # output type matches the list's element type
return items[0]
first([1, 2, 3]) # type checker knows this is int
first(["a", "b"]) # ...and this is str
T links the parameter and return type, so the checker tracks the concrete type through the
call — Any would have thrown that information away.
The modern syntax (3.12+)
Python 3.12 added clean built-in generic syntax — no explicit TypeVar import needed. Both
styles are common; you'll see the old one in existing code.
# 3.12+
def first[T](items: list[T]) -> T:
return items[0]
# pre-3.12 (still valid)
from typing import TypeVar
T = TypeVar("T")
def first(items: list[T]) -> T: ...
The new syntax is more readable and scopes the type parameter to the function or class.
Generic classes
A class can be generic too, parameterised by one or more type variables. This is how
list[int] or a typed container carries its element type.
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()
s: Stack[int] = Stack()
s.push(1)
reveal_type(s.pop()) # int
# 3.12+: class Stack[T]: ...
Now Stack[int] and Stack[str] are distinct to the type checker, and pop returns the
right element type.
Bounded and constrained type variables
You can restrict what a type variable accepts. A bound requires a subtype; constraints limit it to a fixed set of types.
from typing import TypeVar
# bound: T must be (a subclass of) a type that supports comparison
Comparable = TypeVar("Comparable", bound="SupportsLessThan")
def maximum(a: Comparable, b: Comparable) -> Comparable:
return a if a > b else b
# constrained: T is exactly int or str, nothing else
Numeric = TypeVar("Numeric", int, float)
def double(x: Numeric) -> Numeric:
return x * 2
A bound says "at least this"; constraints say "one of exactly these."
Protocols: structural typing
A Protocol defines an interface by shape — any class with the right methods matches, no
inheritance required. This is static checking for duck typing.
from typing import Protocol
class Drawable(Protocol):
def draw(self) -> str: ...
def render(obj: Drawable) -> None:
print(obj.draw())
class Circle: # does NOT inherit Drawable
def draw(self) -> str:
return "○"
render(Circle()) # type-checks — it has draw()
Circle satisfies Drawable simply by having a compatible draw method. This is far more
Pythonic than forcing an explicit base class.
runtime_checkable and where protocols shine
Decorate a Protocol with @runtime_checkable to allow isinstance checks against it (it
checks method presence, not signatures). Protocols are ideal for typing the "anything with
these methods" parameters that pervade Python.
from typing import Protocol, runtime_checkable
@runtime_checkable
class SupportsClose(Protocol):
def close(self) -> None: ...
isinstance(open("f"), SupportsClose) # True — files have close()
Use Protocols for dependency interfaces, plugin contracts, and standard "supports X" shapes like iterables and context managers.
Recap
Generics keep type information flowing through reusable code: a TypeVar (or the
3.12 def f[T] / class C[T] syntax) links inputs to outputs, and generic classes
(Generic[T]) carry an element type like Stack[int]. Restrict type variables with a
bound ("at least this") or constraints ("one of these"). Protocols bring static
checking to duck typing — a class matches by shape, not inheritance — and
@runtime_checkable enables isinstance checks. Together they let you type flexible Python
precisely without sacrificing its dynamism.