Python type annotations have matured from optional documentation into an indispensable static analysis toolchain that catches contract failures before code reaches staging.
This is a field guide for that approach. Not a catalog of every feature in typing, and not a purity contest between checkers. The goal is fewer production surprises with a budget you can defend in code review.
What "pays off" actually means
A hint pays off when it does at least one of these jobs:
- Stops a real class of bug before merge (wrong key on a dict from JSON, missing attribute after a rename,
Nonewhere a string is required). - Makes refactors safe across modules you do not hold in your head.
- Documents contracts that tests alone do not spell out (shape of a request body, methods a storage backend must implement).
- Costs little to maintain when the code changes.
If a annotation only satisfies the checker, or needs a cast every week, it is debt wearing a green CI badge.
Start at the edges, not the middle
When a codebase has zero types, annotating random utilities is the slowest path. Order that works in practice:
- Public functions and methods that cross package boundaries.
- Data that crosses process boundaries: HTTP bodies, queue messages, config files, ORM row shapes you treat as dicts.
- Interfaces between components you swap or mock (storage, payment clients, feature flags).
- Only then deeper internals, once the edges are honest.
A service that has perfect types on private helpers and dict[str, Any] on every FastAPI handler is typed where it hurts least.
# Edge: request in, domain out
def create_invoice(payload: CreateInvoiceRequest, user_id: str) -> Invoice:
...
Return types matter as much as parameters. Callers guess wrong on returns more often than on arguments.
TypedDict: JSON and config without a full model layer
Pydantic and dataclasses are great when you own the model layer. Plenty of production code still passes plain dicts from json.loads, Redis, or a third-party SDK. That is where TypedDict earns its keep.
from typing import TypedDict, NotRequired
class UserEvent(TypedDict):
user_id: str
event: str
ts: int
meta: NotRequired[dict[str, str]]
def handle_event(event: UserEvent) -> None:
user_id = event["user_id"] # checker knows the key exists
...
Why this pays off:
- Renaming
user_idtoaccount_idfails at every call site in CI, not in one silent log line at 3 a.m. - Optional fields stay explicit with
NotRequired(ortotal=Falsefor older styles). - You do not force a full class hierarchy onto a thin adapter.
Skip TypedDict when the shape is truly open-ended (arbitrary vendor webhooks you only store), or when you already validate with a schema library that generates types. Double modeling the same payload is busywork.
For nested JSON, prefer a few small TypedDicts over one mega-dict with ten optional keys and a prayer.
Protocol: duck typing that still checks
Python's strength is structural typing. Protocol (PEP 544) lets you keep that style without inventing base classes only the type checker cares about.
from typing import Protocol
class SupportsClose(Protocol):
def close(self) -> None: ...
class UserStore(Protocol):
def get(self, user_id: str) -> User | None: ...
def save(self, user: User) -> None: ...
def shutdown(resource: SupportsClose) -> None:
resource.close()
Any object with a compatible close() method works. No ABC registration, no shared library dependency just for typing.
Where Protocols shine in production:
- Ports and adapters: define
UserStore, inject Postgres in prod and a fake in tests. - Library-friendly APIs: accept "anything file-like" instead of a concrete class.
- Incremental extraction: document the methods you actually call before you extract an interface class.
Skip Protocols for one-off internal classes with a single implementation that never changes. A concrete class annotation is clearer. Also avoid Protocols that list twenty methods "for completeness." Type what callers use.
# Good: small, real
class Clock(Protocol):
def now(self) -> datetime: ...
# Noise: god interface nobody implements fully
class EverythingService(Protocol):
...
Unions, Optional, and | None (the bugs you actually ship)
Most production type wins are boring: a function returns User | None, a caller forgets the check, the checker yells.
def find_user(user_id: str) -> User | None:
...
user = find_user(uid)
# name = user.name # error: Item "None" has no attribute "name"
if user is None:
raise LookupError(uid)
name = user.name # narrowed; safe
Prefer explicit X | None over returning sentinel empty objects that pretend success. Prefer raising for programmer errors and None (or Result) for expected absence. Pick one style per codebase and stick to it.
Union of five unrelated types is usually a design smell. If a function returns User | Order | str | int, split the API.
Generics where shared containers earn them
Generics pay off on reusable containers and repositories, not on every local variable.
from typing import TypeVar, Generic
T = TypeVar("T")
class Repository(Generic[T]):
def get(self, id: str) -> T | None: ...
def add(self, item: T) -> None: ...
class UserRepository(Repository[User]):
...
Or, with modern syntax and a checker that supports it, keep helpers generic without ceremony:
def first[T](items: list[T]) -> T | None:
return items[0] if items else None
Skip deep generic graphs (Repo[T, ID, Filter, Page]) until you feel real pain. Complex TypeVar bounds are a common place teams burn a week and ship the same bugs with prettier signatures.
mypy and pyright: make the tool serve the team
You do not need a holy war. You need one checker in CI with a config the team can explain.
Practical setup that works:
- Pick one primary checker for CI (pyright/Pylance is common in VS Code shops; mypy is common in older Django/Flask monorepos). Local editor feedback can match CI.
- Start gradual.
strict = trueon a million-line legacy tree is how typing initiatives die. - Tighten by package. Core domain and public APIs go strict first. Scripts and notebooks stay loose.
- Fail the build on errors in typed modules, not on the whole universe of third-party stubs on day one.
Example pyrightconfig.json shape for a gradual rollout:
{
"include": ["src"],
"exclude": ["**/migrations", "**/scripts"],
"typeCheckingMode": "basic",
"reportMissingImports": true,
"reportOptionalMemberAccess": true
}
Example mypy path toward honesty without a big bang:
[mypy]
python_version = 3.12
warn_return_any = True
warn_unused_ignores = True
check_untyped_defs = True
[mypy-src.legacy.*]
ignore_errors = True
What to demand from the checker
- Catch attribute and None mistakes on your code.
- Catch wrong TypedDict keys and Protocol mismatches.
- Surface unused ignores so
# type: ignoredoes not become permanent wallpaper.
What not to worship
- Perfect third-party coverage. Use stubs when they help; wrap messy SDKs behind a thin typed facade instead of annotating their guts.
- Zero
Any. A few honestAnyat the system boundary beat fifty fake precise types that are wrong. - Winning an argument about which checker is "more correct" while production still ships
dicteverywhere.
cast, Any, and # type: ignore (the escape hatches)
These tools exist for a reason. Abuse patterns to watch:
# Bad: silencing instead of modeling
user = cast(User, raw_json) # hope-driven development
data: Any = fetch() # contagion spreads to callers
result = thing.method() # type: ignore[attr-defined]
Better patterns:
def parse_user(raw: dict[str, object]) -> User:
# validate once at the boundary
return User(
id=str(raw["id"]),
email=str(raw["email"]),
)
Rules that hold up:
cast: rare, local, and preferably next to a runtime check or a comment that says why the checker cannot see the truth.Any: allowed at untyped borders; quarantine it. Do not returnAnyfrom core domain functions.# type: ignore: requires a code, ideally a ticket or comment.warn_unused_ignoresshould be on so dead ignores get deleted.
What to skip (on purpose)
Not every typing feature deserves a production rollout:
| Feature / habit | When it pays off | When to skip |
|---|---|---|
| Annotating every private one-liner | Almost never | Default skip |
Full strict on day one of a legacy app |
Greenfield or small core | Skip until edges are typed |
| Overbuilt Generic hierarchies | Shared libraries, collections | App code with one use site |
Runtime typing abuse (isinstance with complex aliases) |
Rare validation helpers | Hot paths; keep runtime checks simple |
| Duplicating Pydantic models as TypedDicts | N/A | Pick one source of truth |
| Protocols for single implementations | Multi-impl ports | Concrete class is enough |
| Typing throwaway notebooks and one-off migrations | Rarely | Leave them alone |
ParamSpec / advanced callback typing |
Framework and decorator authors | Most app code |
Also skip fighting the language. Python will not become Rust. The point is cheaper refactors and fewer AttributeErrors in prod, not a theorem prover.
A realistic rollout for an existing service
- Turn on the checker in CI in non-blocking or limited path mode for a week so noise is visible.
- Type the HTTP/queue boundary with TypedDict or your schema library's exported types.
- Add Protocols for two or three real ports (db, cache, mailer) and use them in tests.
- Enable optional member checks and fix the
Nonefallout; this alone usually pays for the migration. - Ban new untyped public functions in review for the core package.
- Tighten one package per sprint, delete unused ignores as you go.
- Measure: count of production bugs from bad shapes, time to rename a field, how often people bypass the checker. If those do not move, your annotations are theater.
Patterns that keep aging well
Boundary validation, internal trust. Parse untrusted input once into a typed shape. Inside the app, pass User, not dict.
Narrow at conditionals. Rely on checker narrowing after if x is None, isinstance, and tagged unions instead of re-asserting types by hand.
Prefer list[str] and dict[str, int] (PEP 585) on modern Python over List and Dict from typing in new code.
Keep annotations close to truth. If production can send an extra field, do not pretend the type forbids it unless you strip it at the boundary.
Document non-obvious types in the signature, not a paragraph. def price_cents(...) -> int beats a comment that says "returns cents."
Minimal example: a seam that catches real mistakes
from typing import Protocol, TypedDict, NotRequired
class ChargeRequest(TypedDict):
customer_id: str
amount_cents: int
currency: str
idempotency_key: NotRequired[str]
class PaymentGateway(Protocol):
def charge(self, req: ChargeRequest) -> str: ...
def place_order(
gateway: PaymentGateway,
customer_id: str,
amount_cents: int,
) -> str:
req: ChargeRequest = {
"customer_id": customer_id,
"amount_cents": amount_cents,
"currency": "USD",
}
return gateway.charge(req)
A test double only needs charge. A typo in amount_cents fails before deploy. Swapping Stripe for a fake does not require a shared base class. That is production typing: small contracts, enforced where money and data cross lines.
Bottom line
Type hints pay off when they protect contracts, not when they decorate implementation details. Prefer:
- Honest annotations on public APIs and wire data (
TypedDictor schema models) - Small Protocols at swappable boundaries
- None-safety and simple unions that match real control flow
- One checker in CI, gradual strictness, few escape hatches and fewer lies
Skip ceremony that does not change bug rates. Python typing is a tool for teams shipping services under change. Use it like an engineer, not like a collector of PEPs.
