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.
def 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
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.
# 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.
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.
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