skip to content

Gradual Typing in Practice

Getting a partly-untyped codebase under a checker: where types for third-party code come from, and how to stage adoption without a rewrite. Most Python roles mean typing code that shipped years ago.

part ofPythonoverview, primer and where to startread it →
on this pageshow

questions

12

How would you stage adding type hints to a large untyped Python codebase?

level: middleimportance: must knowfreq 55%

answer

  1. Types arrive in jumps, not smoothly
  2. Which functions do other modules import?
  3. Signatures before locals; return types first
  4. Name the data crossing the boundary
  5. Annotation-only commits, then a ratchet

basics

~20 s

Start at the public boundary: annotate the function and method signatures other modules import, then work inward. Type one module per change, keep annotation-only diffs separate from behaviour changes, and let the checker infer locals.

solid answer

~50 s

I treat it as a migration, not a formatting pass. First I annotate the **public boundary** of a module: the parameters and return types of the functions other modules import, plus the shapes of the data crossing that boundary (a `TypedDict` or a dataclass instead of a bare `dict`). Callers immediately get real types even though the bodies are still loose. Then I work inward, module by module, roughly in import order so a module I annotate already sees typed dependencies. Two habits keep it sane: annotation-only commits, never mixed with refactors, so review is cheap and a revert is safe; and a ratchet so a module that is typed stays typed. Since Python 3.14 annotations are evaluated lazily, so adding them to legacy modules costs nothing at import time and rarely needs quoted forward references.

code

python · 14 lines
python
from typing import TypedDict


class Record(TypedDict):
    accession: str
    year: int


def ingest(records: list[Record]) -> int:
    """The module's public boundary: annotated first."""
    return sum(1 for record in records if record["year"] > 1900)


print(ingest([{"accession": "A-1", "year": 1912}]))

go deeper

for a junior

Be ready to say what an annotation does and does not do: it records an intended type for tools and introspection, and it does not check or convert anything when the function runs. Knowing that is enough to see why adding types to old code is safe.

for a middle

Explain the mechanics: a function with no annotations is opaque to the checker and its body is usually skipped, so signatures at a module's public surface pay off first, return types most of all. Mention keeping annotation-only diffs separate.

for a senior

Show the migration judgment: ordering modules by the import graph and by where shape bugs actually occur, replacing bare dicts at the boundary with named shapes, and installing a ratchet so typed modules cannot silently regress.

for a principal

Own the strategy question: what the rollout is buying, which modules deserve the effort, and how progress is measured. Argue for 'new code is typed by default' over a coverage percentage, and be explicit about where you deliberately stop.

## The shape of the problem An untyped codebase does not become useful to a checker gradually in proportion to how many annotations you add. Value arrives in jumps, and where you put the first annotations decides how big those jumps are. The reason is how gradual typing works: an unannotated function is, to a checker, a black box. Its parameters and its return are unknown, and most checkers do not even analyse the body of a function that has no annotations at all. So a hundred annotations sprinkled on local variables inside private helpers buy almost nothing, while ten annotations on the signatures at a module's public surface make every caller in the codebase checkable. ## Boundary first, then inward The *public boundary* of a module is the set of names other modules import: the functions, methods and classes that appear in someone else's `from x import y`. Annotate those signatures first — parameters and, above all, return types. A missing return type is the single most expensive omission, because the value flows outward into caller code and takes the checking blackout with it. At the same time, name the data crossing that boundary. Legacy Python moves dictionaries around; a boundary annotated `dict` is barely better than none. Introduce a `TypedDict` for a record that really is a JSON-shaped mapping, or a dataclass where you can afford to construct objects. That one act converts a stringly-typed interface into something the checker can verify at every call site. Only then work inward: private helpers, then locals that inference cannot resolve. Most locals never need an annotation — inference handles them, and annotating them adds diff noise without adding checking. ## Ordering across modules Within a codebase, prefer to annotate in dependency order: modules with few internal imports first, then their consumers. If you annotate a consumer before its dependency, everything it receives is still unknown and you end up writing speculative annotations you later have to correct. Working from the leaves up means each module you touch already sees typed inputs, so the checker's complaints are real findings rather than noise about unknown types. There is a tension between "leaves first" and "the module with the most callers first". Resolve it by value: the module whose shapes are most often misunderstood — the one at the middle of the import graph that everyone passes records through — is worth doing early even if its dependencies are untyped, because that is where the shape bugs live. ## Keep the diff boring Annotation commits should change no runtime behaviour. Annotations are inert: they are stored for introspection and, since Python 3.14 (PEP 649/749), not even evaluated until something asks for them. That property is what makes the migration safe, and it is worth protecting. Mixing an annotation pass with a refactor destroys it — a reviewer can no longer tell whether the checker's silence means "correct" or "unchanged". So: one module, annotations only, and a separate commit for any behaviour fix the annotations exposed. It is common for the first typing pass over a legacy module to *find* a bug; write it down and fix it separately. ## Ratchet, do not sprint A typed module drifts back to untyped the moment somebody adds an unannotated function to it. The cheap defence is a list of modules that are considered done, enforced wherever your checker runs, plus a review habit: in a file that is already typed, a new function without annotations is a review comment, the same as a missing test. Progress is then monotonic, and the metric that matters is not "percent annotated" but "is new code typed by default". ## What it looks like in practice On a museum-catalogue importer, the first change is not the parser internals. It is `def ingest(records: list[Record]) -> IngestReport:` at the top of the package, with `Record` given a real shape. Every scheduler, every test and every downstream report immediately gets checked against that contract, and the untyped parsing internals underneath can be annotated over the following weeks without blocking anyone.

  • Why is a missing return annotation more costly than a missing parameter annotation?
    A parameter's unknown type stays inside the function. An unknown return value flows outward into every caller, and each expression derived from it becomes unchecked too, so one missing return type can blank out checking across a large slice of caller code. Annotating returns first is the highest-leverage move in a gradual migration.
  • How do you stop a module you have just typed from drifting back to untyped?
    Keep an explicit list of modules considered done and have the checker run against it wherever the team already runs checks, so a regression fails loudly rather than silently. Pair it with a review habit: in a file that is already annotated, a new function without annotations gets the same review comment a missing test would. The point is monotonic progress, not a coverage percentage.
  • The first typing pass over a legacy module surfaces a real bug. What do you do with it?
    Record it, and fix it in a separate commit from the annotations. An annotation-only change is provably behaviour-preserving and can be reviewed quickly or reverted safely; the moment it also changes logic, that property is gone and the reviewer cannot tell which half caused a regression. Ship the annotations, then ship the fix with a test.

saying these in an interview costs you the question

  • Annotate the entire codebase in one giant pull request
  • Start with local variables inside private helper functions
  • Add Any everywhere until the checker goes quiet
  • Claims annotations enforce types and slow the code down
  • Mixes annotation passes with refactors in one commit

context

open as a page

What is a `.pyi` stub file in Python, and does it override inline annotations?

level: middleimportance: must knowfreq 45%

basics

~20 s

A .pyi stub is a Python-syntax file carrying only signatures, with ... for every body. A static type checker reads the stub instead of the matching .py module, so it fully overrides that module's inline annotations.

open as a page

How does moving an import under if TYPE_CHECKING: break an import cycle?

level: middleimportance: must knowfreq 52%

basics

~20 s

The cycle exists only because both modules import each other while running. If one direction is needed purely for annotations, guarding it with TYPE_CHECKING deletes that runtime edge, and the checker still sees both modules, so the hints keep resolving.

open as a page

What is typing.TYPE_CHECKING, and why guard an import with it?

level: juniorimportance: should knowfreq 42%

basics

~20 s

typing.TYPE_CHECKING is a constant that is False whenever the program actually runs, but every static type checker analyses the block as if it were True. An import placed inside that block therefore exists only for the checker.

open as a page

When do you reach for typing.cast() versus a `# type: ignore` comment?

level: middleimportance: should knowfreq 45%

basics

~20 s

Use typing.cast when you know the value's real type and the checker cannot see it; it returns the value unchanged at runtime. Use a suppression comment, with its error code, only when the checker itself is wrong.

open as a page

What is typeshed, and where does a type checker get stdlib and third-party stubs?

level: middleimportance: should knowfreq 30%

basics

~20 s

typeshed is the central repository of .pyi type stubs. Type checkers vendor its standard-library stubs instead of reading CPython's source, and its third-party stubs ship as separate types-* distributions. An installed stubs package outranks a library's own inline annotations.

open as a page

How do you use a typing feature your project's minimum Python lacks?

level: middleimportance: should knowfreq 40%

basics

~20 s

Import the name from the typing back-port distribution, which mirrors newer typing names onto older runtimes, or branch on sys.version_info between the standard-library name and a fallback. If the name is used only in annotations, a TYPE_CHECKING-guarded import needs no dependency at all.

open as a page

How do you keep typing.Any from spreading through a partly typed module?

level: seniorimportance: should knowfreq 40%

basics

~20 s

Convert unknown values into a named shape at the point they enter, annotate every return type so unknowns cannot flow outward, and prefer object over Any when you genuinely do not know, because object forces a narrowing check.

open as a page

How do you give a compiled Python extension module type information with no source?

level: seniorimportance: should knowfreq 22%

basics

~20 s

Ship a hand-written .pyi stub beside the compiled binary inside the package, plus a py.typed marker so consumers' checkers use it. A checker resolves the extension module through the stub; the binary itself carries nothing a checker can read.

open as a page

How do you scope a typing rollout for a legacy Python importer, and decide when it has gone far enough?

level: principalimportance: should knowfreq 30%

basics

~20 s

Scope by where shape bugs actually occur and where interfaces cross team lines, not by coverage percentage. Stop when new code is typed by default and the remaining untyped code is stable, low-traffic and cheap to leave alone.

open as a page

When should a Python library ship types inline rather than as a stubs distribution?

level: principalimportance: should knowfreq 18%

basics

~20 s

Ship inline with a py.typed marker whenever you own source that can carry annotations: one artefact, no drift, and the types are checked against the implementation. Reserve a -stubs distribution for code you do not own or compiled modules.

open as a page

How do you retire a project's sys.version_info shims after raising its Python floor?

level: seniorimportance: nice to knowfreq 18%

basics

~20 s

Raise requires-python first, then delete each dead branch and its alias, drop the back-port dependency only where nothing evaluates it at runtime, and trim the CI matrix. Remember the branch you are deleting was never type-checked.

open as a page