Type hints

Annotate code so a checker can find bugs before it runs — and know exactly how much Python itself does about them at runtime.

21of 24
≈ 13Min read
5Parts

Annotations are not enforced

A type hint is metadata. The interpreter stores it and runs the code exactly as it would have anyway. What reads it is a checker you run separately — mypy, pyright, or the one built into your editor — plus libraries that choose to look.

That is the trade: full static checking when you want it, with a language that never pays for it at runtime.

py
def double(n: int) -> int:
    return n * 2

double("ha")        # 'haha' — Python does not check, and does not care

The notation, in the modern spelling

Since 3.9 the built-in containers are generic — write list[int], not typing.List[int]. Since 3.10, X | None replaces Optional[X].

Accept the widest type you can and return the narrowest: a function that takes Iterable[str] works on a list, a tuple, a set and a generator; one that takes list[str] only works on a list.

py
name: str = "ada"
counts: dict[str, int] = {}
rows: list[tuple[str, int]] = []
maybe: str | None = None                 # 3.10+; used to be Optional[str]
number: int | float                      # used to be Union[int, float]

def total(prices: list[float], tax: float = 0.2) -> float: ...
def log(*args: str, **fields: int) -> None: ...

from collections.abc import Iterable, Callable, Sequence

def first(items: Iterable[str]) -> str | None: ...
def apply(fn: Callable[[int, int], int], a: int, b: int) -> int: ...

Protocol — typed duck typing

A Protocol describes a shape rather than an ancestry, which is how Python already worked — now with a name the checker understands. Nothing has to inherit from it, and the classes that satisfy it need never have heard of it.

@runtime_checkable lets isinstance test it, but only by method names, not signatures. It is a convenience, not a guarantee.

py
from typing import Protocol, runtime_checkable

@runtime_checkable
class Closeable(Protocol):
    def close(self) -> None: ...

def shut(thing: Closeable) -> None:      # anything with a close() fits
    thing.close()

isinstance(my_file, Closeable)           # True, thanks to @runtime_checkable

What Python does expose

The annotations are a plain dict on the function, which is why dataclasses, Pydantic, FastAPI and attrs can all do what they do: they read them and generate real behaviour. Your own code can too.

py
def area(width: int, height: int = 1) -> int: ...

area.__annotations__             # {'width': int, 'height': int, 'return': int}

from typing import get_type_hints, get_origin, get_args
get_type_hints(area)             # the same, with string annotations resolved
get_origin(list[int])            # list
get_args(dict[str, int])         # (str, int)
get_args(str | None)             # (str, NoneType)

import inspect
inspect.signature(area)          # (width: int, height: int = 1) -> int

Using it in practice

  • Annotate the edges first: public functions, module boundaries, anything crossing a package. Local variables usually infer fine.
  • Turn the checker on in CI, as the tests lesson said about tests — a type error that only your editor sees is one nobody fixes.
  • Any is an escape hatch, not a default. It switches checking off for everything it touches.
  • Start loose and tighten: mypy has --strict, and adopting it gradually beats never adopting it.
  • Annotations are documentation that cannot drift, because the checker fails when they do.

Practice