Frontend Labs
⋯

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 are list, dict.
  • Use the built-in generics (list[int], dict[str, int]) and X | None (3.10+), not typing.List or Optional.
  • Any turns checking off for that value. Use it at boundaries (raw JSON), then narrow it into a TypedDict or 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.


Generated from real-life-python-guide.md · 8 Oct 2026 16:36