Python Advanced

Type Hints in Production Python: Generics, Protocols, mypy --strict, and pydantic v2

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.

DjangoZen Team Apr 25, 2026 20 min read 6 views

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.

Why --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"]

Generics with TypeVar and ParamSpec

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.

Protocols — duck typing with teeth

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.

TypedDict and Literal for JSON

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.

Runtime validation with pydantic v2

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})

Pitfalls you will hit

  • list vs Sequence. Accept the most general type, return the most specific. Function args: Sequence[int]. Return: list[int].
  • Mutable default args. def f(x: list[int] = []) is a bug, type-checked or not.
  • Narrowing with isinstance. mypy follows it; type(x) is X doesn't narrow.
  • Forward references. Use "User" in quotes if the class is defined later, or from __future__ import annotations at the top.
  • Avoid Any like the plague. If you need it, use object and narrow, or cast() with a comment explaining why.

Migrating an untyped codebase

Don't try to type everything at once. Strategy:

  1. Run mypy with --strict --ignore-missing-imports on one module.
  2. Add types to that module. Mark it as strict-by-file: [[tool.mypy.overrides]] module = "myapp.payments" strict = true.
  3. Move outward. Boundaries first: HTTP handlers, DB layer, external APIs.
  4. Use monkeytype to suggest types from runtime traces if you're stuck.

Summary

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.