backend / code quality / 03_type_checking_strategy.md

Type-Checking Strategy

6 interview angles 6 min read source

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.

  1. 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.
  2. Type new code strictly. New modules and changed functions get full annotations. The typed surface grows organically with normal work.
  3. Per-module strictness overrides. mypy lets you set strictness per module:
    # 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
    As you clean up a module, you ratchet its settings tighter. The ratchet only goes one way — a module never gets less strict.
  4. 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 bare list, must be list[int].
  • no_implicit_optionaldef f(x: int = None) is an error; must be int | None.
  • warn_return_any — flags returning an Any from a typed function (the silent hole where Any leaks 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 object when you mean “any type” but want to keep checking (you must narrow it before use).
  • Prefer Unknown/explicit generics over bare containers.
  • # type: ignore should be specific (# type: ignore[arg-type]) and rare — a comment explaining why helps. warn_unused_ignores catches ignores that are no longer needed.
  • Untyped third-party libraries are the common Any source — install their types-* 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 --incremental cache (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

  • --strict on 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.
  • Any leaking — one untyped function poisons typed callers. warn_return_any, install stubs, prefer object.
  • Blanket # type: ignore — hides real errors. Make it specific ([error-code]), explain why, let warn_unused_ignores clean 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 Any dangerous?” — it disables checking and spreads: one untyped function called by typed code poisons the type info downstream. Prefer object (still checked, must narrow), install types-* stubs for untyped libraries, and use warn_return_any to catch Any leaking back through typed functions.
  • “What does mypy --strict give you?” — a bundle; the high-value flags are disallow_untyped_defs (everything annotated), disallow_any_generics (no bare list), no_implicit_optional, and warn_return_any (stops Any leaking). It’s the target end-state of the ratchet, not the starting point.