Type-Checking Strategy
Type hints aren’t just documentation — adopted well, they’re a static safety net that catches a whole class of bugs before runtime. The senior question isn’t “do you use types” — it’s “how do you roll type checking into an existing untyped codebase without stalling the team.”
The checkers
| Tool | Notes |
|---|---|
| mypy | the reference checker; mature, widely used; gradual typing first-class |
| pyright / Pylance | Microsoft’s; very fast, excellent inference, powers VS Code’s Python type checking |
| ty / pyrefly | newer Rust-based checkers (Astral, Meta) — fast, still maturing |
mypy and pyright disagree in places (inference depth, strictness defaults). Pick one as the CI source of truth so the build is deterministic; developers can run whatever their editor uses locally, but CI gates on one.
Gradual typing — the whole point
Python’s type system is gradual: typed and untyped code coexist. An unannotated function is treated as Any — the checker doesn’t complain, but it also can’t help. This is what makes incremental adoption possible: you don’t have to type the whole codebase at once.
def process(data): # untyped — checker treats it as Any-in, Any-out
return data["key"]
def process(data: dict[str, int]) -> int: # typed — checker now verifies callers + body
return data["key"]
Adopting types in a legacy codebase — the strictness ratchet
The mistake: turn on --strict repo-wide, get 4,000 errors, give up. The strategy: a ratchet that only tightens.
- Start permissive, but enforced. Turn mypy/pyright on in CI with lenient settings — it passes today on the existing code. Now any new error fails the build.
- Type new code strictly. New modules and changed functions get full annotations. The typed surface grows organically with normal work.
- Per-module strictness overrides. mypy lets you set strictness per module:
As you clean up a module, you ratchet its settings tighter. The ratchet only goes one way — a module never gets less strict.# mypy.ini / pyproject.toml [mypy] ignore_missing_imports = true [mypy-myapp.core.*] # the core package is fully strict disallow_untyped_defs = true strict = true [mypy-myapp.legacy.*] # legacy is still permissive ignore_errors = true - Eventually flip the default to strict once enough of the codebase is clean, with the few remaining legacy modules explicitly opted out.
The key property: the error count can only go down. No flag day, no stalled team, the typed surface monotonically grows.
What “strict” actually turns on
mypy --strict is a bundle; the ones that matter most:
disallow_untyped_defs— every function must be annotated.disallow_any_generics— no barelist, must belist[int].no_implicit_optional—def f(x: int = None)is an error; must beint | None.warn_return_any— flags returning anAnyfrom a typed function (the silent hole whereAnyleaks back in).disallow_untyped_decorators,warn_unused_ignores, etc.
warn_return_any and disallow_any_generics are the high-value ones — they stop Any from quietly spreading through an otherwise-typed codebase.
Any is the hole in the net
Any disables checking — anything is assignable to Any and Any is assignable to anything. A few Anys leak: one untyped function called by typed code poisons the type information downstream.
- Prefer
objectwhen you mean “any type” but want to keep checking (you must narrow it before use). - Prefer
Unknown/explicit generics over bare containers. # type: ignoreshould be specific (# type: ignore[arg-type]) and rare — a comment explaining why helps.warn_unused_ignorescatches ignores that are no longer needed.- Untyped third-party libraries are the common
Anysource — install theirtypes-*stubs (types-requests,types-redis) or write a minimal local stub.
Runtime types vs static types — they’re different things
Type hints are erased at runtime — def f(x: int) does not check x at runtime; pass it a string and Python runs happily until something breaks.
| Need | Tool |
|---|---|
| Static analysis (catch bugs before running) | mypy / pyright |
| Runtime validation at system boundaries (API input, config, queue messages) | Pydantic |
| Lightweight runtime structs without validation | dataclasses / attrs |
The senior framing: static checking for internal correctness, Pydantic for boundary validation. Don’t reach for Pydantic everywhere — it has real overhead; use it where untrusted data enters (request bodies, env config, deserialized messages). See 06_web_frameworks/pydantic/ and 02_python_core/stdlib/.
Typing features worth knowing
The repo’s 02_python_core/typing/ covers these in depth — for a code-quality discussion, the headline ones:
Protocol— structural typing (“has these methods”) without inheritance; how you type duck-typed interfaces.TypedDict— typed dict shapes (JSON payloads).Literal,Final,Annotated— narrow types, constants, metadata.TypeVar/ generics /ParamSpec— generic functions and classes.cast()— assert a type to the checker when you know better than it does (use sparingly; it’s a promise, not a check).
CI integration
# in CI, after lint
- run: mypy myapp/ # or pyright
- Gate the build on it — a type error fails CI like a test failure.
- Make it fast — pyright is quick; mypy has a
--incrementalcache (cache it between CI runs). - Run it as a pre-commit hook too so developers catch errors before pushing (see 04_pre_commit_and_review.md).
Common gotchas
--stricton day one — thousands of errors, team gives up. Ratchet instead.- Thinking hints are runtime checks — they’re erased;
def f(x: int)doesn’t validate. Pydantic for runtime. Anyleaking — one untyped function poisons typed callers.warn_return_any, install stubs, preferobject.- Blanket
# type: ignore— hides real errors. Make it specific ([error-code]), explain why, letwarn_unused_ignoresclean up stale ones. - mypy and pyright disagreeing — pick one as CI’s source of truth; don’t gate on both.
- No CI gate — types that aren’t checked in CI rot. If it’s not enforced, it’s not real.
Interview angle
- “How do you introduce type checking into a large untyped codebase?” — a strictness ratchet: turn the checker on in CI with lenient settings so it passes today and any new error fails the build; type new and changed code strictly; tighten per-module strictness as you clean modules up; eventually flip the default to strict with legacy modules explicitly opted out. The error count only goes down — no flag day.
- “What’s gradual typing?” — Python lets typed and untyped code coexist; an unannotated function is treated as
Any. That’s what makes incremental adoption possible — you type the codebase a module at a time, not all at once. - “Do type hints check anything at runtime?” — no — they’re erased.
def f(x: int)won’t reject a string at runtime. Static checkers (mypy/pyright) catch it before running; Pydantic does the runtime validation, used at system boundaries (API input, config, messages). - “mypy vs pyright?” — both mature; pyright is faster with stronger inference and powers VS Code, mypy is the reference with first-class gradual typing. They disagree on strictness/inference, so pick one as CI’s source of truth for a deterministic build.
- “Why is
Anydangerous?” — it disables checking and spreads: one untyped function called by typed code poisons the type info downstream. Preferobject(still checked, must narrow), installtypes-*stubs for untyped libraries, and usewarn_return_anyto catchAnyleaking back through typed functions. - “What does
mypy --strictgive you?” — a bundle; the high-value flags aredisallow_untyped_defs(everything annotated),disallow_any_generics(no barelist),no_implicit_optional, andwarn_return_any(stopsAnyleaking). It’s the target end-state of the ratchet, not the starting point.