Move beyond "int and str" type hints. Master TypeVar generics, structural typing with Protocol, TypedDict for JSON, mypy strict mode, and runtime validation with pydantic v2 — what production codebases actually use.
Type hints stopped being optional. In 2026, almost every serious Python codebase runs mypy --strict in CI and uses pydantic for any data crossing a boundary. This tutorial is the production-grade tour: not "what are types," but how to use them well in code that ships.
--strict?Default mypy is permissive — untyped functions are silently skipped. --strict turns on every check: no implicit Optional, no untyped defs, no unreachable code. It's annoying for two days and load-bearing for two years. Add to pyproject.toml:
[tool.mypy]
python_version = "3.12"
strict = true
warn_unused_ignores = true
warn_redundant_casts = true
disallow_any_generics = true
plugins = ["pydantic.mypy"]
from typing import TypeVar, Generic, Callable, ParamSpec
T = TypeVar("T")
P = ParamSpec("P")
class Cache(Generic[T]):
def __init__(self) -> None:
self._d: dict[str, T] = {}
def get(self, key: str) -> T | None:
return self._d.get(key)
def set(self, key: str, value: T) -> None:
self._d[key] = value
def memoize(fn: Callable[P, T]) -> Callable[P, T]:
cache: dict[tuple, T] = {}
def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
key = (args, tuple(sorted(kwargs.items())))
if key not in cache:
cache[key] = fn(*args, **kwargs)
return cache[key]
return wrapper
ParamSpec propagates the wrapped function's full signature to the wrapper. Without it, decorators erase types and your IDE goes blind.
Use Protocol instead of abstract base classes when you want structural typing — anything that looks like a Repository is one, no inheritance required:
from typing import Protocol
class UserRepository(Protocol):
def get(self, user_id: int) -> "User": ...
def save(self, user: "User") -> None: ...
def register(repo: UserRepository, email: str, password: str) -> int:
user = User(email=email, password_hash=hash(password))
repo.save(user)
return user.id
Now DjangoUserRepository, InMemoryUserRepository, and FakeUserRepository all satisfy the contract without inheriting anything. Tests get an in-memory repo, prod gets the Django ORM repo. Same callsite.
from typing import TypedDict, Literal, NotRequired
class WebhookEvent(TypedDict):
type: Literal["payment.succeeded", "payment.failed", "refund.created"]
id: str
amount: int
currency: Literal["EUR", "USD", "GBP"]
metadata: NotRequired[dict[str, str]]
def handle(event: WebhookEvent) -> None:
if event["type"] == "payment.succeeded":
credit_account(event["id"], event["amount"])
mypy now knows the keys, the literal value-set of type, and that metadata may be absent. Misspell a key and CI fails.
Type hints are only enforced at edit time. The moment data enters from JSON, a form, or a webhook — types lie. pydantic enforces them at runtime, and v2 is fast (Rust core, ~5–50× faster than v1).
from pydantic import BaseModel, EmailStr, Field, field_validator
class CreateUserRequest(BaseModel):
email: EmailStr
password: str = Field(min_length=12, max_length=128)
age: int = Field(ge=18, le=120)
@field_validator("password")
@classmethod
def must_have_digit(cls, v: str) -> str:
if not any(c.isdigit() for c in v):
raise ValueError("password must contain a digit")
return v
# In a Django view:
def signup(request: HttpRequest) -> HttpResponse:
try:
data = CreateUserRequest.model_validate_json(request.body)
except ValidationError as e:
return JsonResponse({"errors": e.errors()}, status=400)
User.objects.create_user(email=data.email, password=data.password)
return JsonResponse({"ok": True})
list vs Sequence. Accept the most general type, return the most specific. Function args: Sequence[int]. Return: list[int].def f(x: list[int] = []) is a bug, type-checked or not.isinstance. mypy follows it; type(x) is X doesn't narrow."User" in quotes if the class is defined later, or from __future__ import annotations at the top.Any like the plague. If you need it, use object and narrow, or cast() with a comment explaining why.Don't try to type everything at once. Strategy:
mypy with --strict --ignore-missing-imports on one module.[[tool.mypy.overrides]] module = "myapp.payments" strict = true.monkeytype to suggest types from runtime traces if you're stuck.Strict typing isn't ceremony — it's documentation that can't lie and a refactor accelerant. Use Generics and ParamSpec for libraries, Protocols for swap-able dependencies, TypedDict for JSON, and pydantic for any data crossing a trust boundary. Once mypy --strict is green, deleting code becomes safe.