Type Hints That Earn Their Place

Annotate the boundaries callers depend on and let a checker find mistakes your tests never reach - but a wrong hint is worse than none.

Syntaxdef parse_timeout(raw: str | None, default: float = 5.0) -> float: ...

Annotations are documentation that a tool can verify. The language itself ignores them: the reference states plainly that the runtime does not enforce function and variable annotations, so def f(n: int) accepts a string and fails later, somewhere else. The value comes from a checker — mypy, pyright — reading them.

Annotate the boundary

Hints pay off where code meets other code: public function signatures, return types, dataclass fields, the shape a parser hands back. Inside a six-line function they add noise, and local variable annotations rarely earn their keep. Start with the module other people import.

Two spellings of everything

Built-in generics have been usable in annotations since 3.9 (PEP 585): list[int] and dict[str, float] mean exactly what typing.List[int] and typing.Dict[str, float] meant, and the typing aliases are deprecated. Since 3.10 (PEP 604) int | None is the same type as Optional[int] and Union[int, None]. Since 3.12 a named alias can be written type Rows = list[dict[str, str]] in place of a plain assignment. Prefer the short forms in new code and recognise the long ones in old.

What a checker catches that a test does not

Checkers see every path, not the ones your fixtures exercise: the None branch of an optional, an attribute renamed in one of nine call sites, an argument swapped with its neighbour. What they cannot catch is a hint that lies, because they trust it — so run the checker in CI, or the annotations drift away from the code and start costing you time instead of saving it.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Document a module's public functions so callers do not have to read the body
  • Find str/Path and None-handling bugs at call sites no test covers
  • Describe the shape of a decoded JSON payload once and have every key use checked
  • Type the seam between your code and an injected client so the real one and the fake stay in step

More examples

The hint, and the error a checker produces from it

No test covered that call, and at runtime "/var/tmp" / "cache" raises TypeError somewhere inside cache_dir. The checker found it from the signature alone. This is the usual first win when you add hints to an existing codebase: str/Path and str/int confusions at call sites nobody exercises.

Example · python
# myapp/config.py
from pathlib import Path


def load_timeout(raw: str | None, default: float = 5.0) -> float:
    if raw is None:
        return default
    return float(raw)


def cache_dir(root: Path, name: str | None = None) -> Path:
    return root / (name or "cache")


# myapp/jobs.py
from myapp.config import cache_dir, load_timeout

timeout = load_timeout("2.5")
path = cache_dir("/var/tmp")        # a str, not a Path

# $ mypy myapp
# myapp/jobs.py:5: error: Argument 1 to "cache_dir" has incompatible type
#     "str"; expected "Path"  [arg-type]
# Found 1 error in 1 file (checked 2 source files)

Naming the shape of decoded JSON

A TypedDict describes a dict with known keys, which is what a decoded JSON object is. Use it at the edge where json.loads output becomes your data: the keys get checked at every use site, while the dict stays a plain dict for serialising back. Note the return PriceRow | None - it forces callers to handle the empty case.

Example · python
from typing import TypedDict


class PriceRow(TypedDict):
    symbol: str
    price: float
    stale: bool


def parse_rows(payload: list[dict[str, object]]) -> list[PriceRow]:
    rows: list[PriceRow] = []
    for item in payload:
        rows.append(
            {
                "symbol": str(item["symbol"]),
                "price": float(item["price"]),     # the cast is the validation
                "stale": bool(item.get("stale", False)),
            }
        )
    return rows


def newest(rows: list[PriceRow]) -> PriceRow | None:
    return rows[0] if rows else None


# row["pirce"] is now an error a checker reports, not a KeyError at 3am.

A Protocol for something you inject

Protocol (3.8+) types the seam between your code and a dependency without making the dependency import anything of yours. Reach for it when you inject a client or a clock: the production object and the test double are checked against the same declared surface, so a renamed method breaks the check rather than only the test.

Example · python
from collections.abc import Sequence
from typing import Protocol


class PriceSource(Protocol):
    # Structural: anything with this method satisfies it, with no base class
    # and no registration - including the fake you write in a test.
    def prices(self, symbols: Sequence[str]) -> dict[str, float]: ...


def total_value(holdings: dict[str, int], source: PriceSource) -> float:
    quotes = source.prices(list(holdings))
    return sum(quotes[symbol] * units for symbol, units in holdings.items())


class FakeSource:
    def prices(self, symbols: Sequence[str]) -> dict[str, float]:
        return {symbol: 10.0 for symbol in symbols}


assert total_value({"ACME": 3}, FakeSource()) == 30.0

Discussion

  • Be the first to comment on this lesson.