18. Type hints that pay for themselves
18.1 What type hints are, and are not
Type hints are annotations that Python stores and ignores at run time. A separate tool, mypy or pyright, reads them and reports errors before you run the code:
def mean(xs: list[float]) -> float:
return sum(xs) / len(xs)
mean(["1", "2"]) # runs until it crashes with TypeError inside sum()...
# ...but mypy reports it without running:
# error: List item 0 has incompatible type "str"; expected "float" [list-item]
In a large codebase, type hints catch the bugs a compiler would have caught
in Go or Rust: wrong argument types, a forgotten None check, a misspelled
attribute, a function that returns str on one path and None on another.
They are also the best documentation: the signature tells you what goes in
and what comes out. Pydantic and FastAPI go further and use hints at run
time to validate data (Ch 44).
18.2 The everyday vocabulary
from collections.abc import Callable, Iterable, Iterator, Mapping, Sequence
from typing import Any, Literal, TypedDict
def first(xs: Sequence[str]) -> str | None: # Sequence: list, tuple, str...
return xs[0] if xs else None
def total(prices: Mapping[str, float]) -> float: # accept any read-only mapping
return sum(prices.values())
def apply(fn: Callable[[int], int], xs: Iterable[int]) -> Iterator[int]:
return (fn(x) for x in xs)
Mode = Literal["train", "eval"]
def run(mode: Mode) -> None: ...
class User(TypedDict):
id: int
name: str
email: str | None
u: User = {"id": 1, "name": "ada", "email": None}
print(first(["a"]), total({"x": 1.5}), list(apply(lambda v: v + 1, [1, 2])), u["name"])
Guidelines:
- Accept abstract, return concrete. Parameters take
Iterable,Sequence,Mapping; return values arelist,dict. - Use the built-in generics (
list[int],dict[str, int]) andX | None(3.10+), nottyping.ListorOptional. Anyturns checking off for that value. Use it at boundaries (raw JSON), then narrow it into aTypedDictor a dataclass immediately.
18.3 Narrowing
The checker follows your control flow:
def port_of(url: str) -> int | None:
return 443 if url.startswith("https") else None
p = port_of("https://x")
# p + 1 # error: Unsupported operand types for + ("None" and "int")
if p is not None:
print(p + 1) # fine: p is int here
isinstance, is None checks, match cases, and early returns all
narrow types. Narrowing is how type hints catch the "forgot to handle
None" bug, Python's equivalent of the
billion-dollar null mistake
that Rust's Option prevents.
18.4 Generics (3.12+ syntax)
from collections.abc import Sequence
def last[T](xs: Sequence[T]) -> T: # T is inferred per call
return xs[-1]
class Stack[T]:
def __init__(self) -> None:
self._items: list[T] = []
def push(self, x: T) -> None:
self._items.append(x)
def pop(self) -> T:
return self._items.pop()
type Pair[K, V] = tuple[K, V] # a generic type alias (3.12+)
s = Stack[int]()
s.push(1)
print(last(["a", "b"]), s.pop())
18.5 Running a type checker
# pyproject.toml
[tool.mypy]
python_version = "3.13"
strict = true # the settings that make hints actually catch bugs
warn_unreachable = true
[tool.ruff.lint]
select = ["E", "F", "I", "B", "UP", "S", "SIM", "RUF"] # pycodestyle, pyflakes, isort, bugbear, pyupgrade, bandit
uv run mypy src/ tests/
Adopt gradually on an existing codebase: start with strict = false, type
the public functions of one module, and make CI fail on new errors only.
reveal_type(x) in your code makes the checker print what it thinks x
is.