Type System
Python's gradual type system in depth -- type hints, Protocols, generics (PEP 695), Self, TypedDict, and exhaustiveness checking
Type System
Python's type system is gradual -- you add types incrementally, and a static checker (mypy/pyright) enforces them while the interpreter ignores them. Used well, type hints carry real design intent: structural Protocols, clean generics, and exhaustiveness checks that turn whole classes of bug into compile-time errors.
Type Hints
Python's type system is gradual -- you add types incrementally. Modern Python (3.10+) type hints are expressive:
from typing import TypeVar, Protocol, overload, runtime_checkable
# Protocol: structural subtyping (like Go interfaces)
@runtime_checkable
class Serializable(Protocol):
def to_dict(self) -> dict: ...
class User:
def to_dict(self) -> dict:
return {"name": self.name}
def serialize(obj: Serializable) -> str:
return json.dumps(obj.to_dict())
# User satisfies Serializable without inheriting from it
# Overload: different return types based on input
@overload
def fetch(id: str, many: Literal[False] = ...) -> User: ...
@overload
def fetch(id: str, many: Literal[True] = ...) -> list[User]: ...
def fetch(id: str, many: bool = False) -> User | list[User]:
if many:
return db.find_all(id)
return db.find_one(id)
# TypeVar with bounds
T = TypeVar("T", bound="BaseModel")
def create_and_save(model_cls: type[T], data: dict) -> T:
instance = model_cls(**data)
instance.save()
return instanceAdvanced Type System
Beyond the basics, the type system carries real design intent. Python 3.12's PEP 695 syntax makes generics far cleaner:
# PEP 695 (3.12+): no more explicit TypeVar declarations
class Repository[T]:
def __init__(self) -> None:
self._items: dict[str, T] = {}
def get(self, key: str) -> T | None:
return self._items.get(key)
def first[T](items: list[T]) -> T | None:
return items[0] if items else None
# Self (3.11+) for fluent builders -- correct even in subclasses
from typing import Self
class QueryBuilder:
def where(self, cond: str) -> Self:
self._conditions.append(cond)
return self
# TypedDict for structured dict payloads at the boundary
from typing import TypedDict, NotRequired
class UserPayload(TypedDict):
id: str
email: str
nickname: NotRequired[str] # optional key, not Optional value
# Exhaustiveness checking -- the type checker errors if you miss a case
from typing import assert_never
def area(shape: Circle | Square) -> float:
match shape:
case Circle(r): return 3.14159 * r * r
case Square(s): return s * s
case _: assert_never(shape) # adding a new shape becomes a type errorTwo distinctions experts hold precisely: T | None (a value that may be None) is not the same as a key that may be absent (NotRequired); and runtime type hints are just annotations -- mypy/pyright enforce them, the interpreter does not. x: int = "no" runs fine.