Type Hints That Earn Their Keep
Annotations a checker can actually use, gradual typing in an untyped codebase, Optional and unions done properly, and the hints that add noise without adding safety.
Annotations a checker can actually use, gradual typing in an untyped codebase, Optional and unions done properly, and the hints that add noise without adding safety.
You are reading a function someone wrote eight months ago:
def process(items, config, retries):
...What is items — a list of paths, a list of photo objects, a
dictionary? Is config your config class or a plain dict? The
only way to find out is to read the body, and then read the
bodies of everything it calls. The information existed in
somebody's head and was never written down.
Type hints write it down, in a form a tool can check. By the end of this lesson you will annotate code usefully, run a checker over it, and know which hints are worth the characters and which are noise — because a codebase that annotates everything indiscriminately is barely better than one that annotates nothing.
def make_caption(filename: str, separator: str = "_") -> str:
words = filename.replace(separator, " ")
return words.title()filename: str says this parameter is a string. -> str says
the function returns one. Variables can be annotated too, though
you rarely need to:
count: int = 0
names: list[str] = []Now the crucial fact: Python ignores all of this at runtime.
make_caption(42) # runs, and fails inside with AttributeErrorNothing checks the hint as the program runs. Annotations are documentation that a separate tool can verify — which is a different and better thing than a runtime check, because it finds the problem before the code ever runs, across every path, including the ones your tests missed.
Hints do nothing on their own. The value arrives with a type checker:
python -m pip install mypy
mypy src/src/photo_tools/captions.py:14: error: Argument 1 to
"make_caption" has incompatible type "int"; expected "str"The alternatives are pyright (fast, what most editors use) and
ty. Any of them; the point is that one runs, in CI, on every
change. Hints nobody checks drift out of date and become
confidently wrong documentation, which is worse than none.
Start permissive and tighten:
[tool.mypy]
python_version = "3.11"
warn_return_any = true
warn_unused_ignores = true
# turn this on per-module as you annotate
disallow_untyped_defs = falseContainers say what is inside them:
| means "either":
That return type is the most useful annotation in Python. It
says out loud that this function sometimes finds nothing — which
is exactly the fact that produces AttributeError: 'NoneType' object has no attribute 'name' when it is not written down. A
checker will now refuse to let you use the result without
handling the None case.
For a value that may be absent:
Anything that can be looped over, without caring what it is:
Iterable accepts a list, a tuple, a set, a generator. Writing
list[int] there would reject three of those for no reason.
And functions, since they are values too:
Read Callable[[Photo], Photo] as "takes a Photo, returns a
Photo".
The rule that decides most annotation questions.
Bad — narrow input, vague output.
Good — broad input, exact output.
Broad. Iterable, not list.
list[Photo] rejects a generator, a tuple and a set even
though the body handles all three — so a caller with a
generator has to build a list purely to satisfy an
annotation.
Exact. int, never Any.
Any here switches the checker off for every caller:
whatever they do with that number, no error is possible.
One vague return type disables checking well beyond the function it is on.
Any means "stop checking". It is sometimes correct — genuinely
dynamic data, a boundary with an untyped library — and it is
contagious in a way people underestimate:
Everything derived from an Any is Any. One vague return type
disables checking across a whole call chain.
The alternative at boundaries is to say what you expect and verify it once:
Now config.timeuot is an error at the point you wrote it.
Sometimes what you need is not a specific type but a capability:
A Protocol says "anything with these methods". Nothing has to inherit from it or know it exists — your own classes, third-party classes and built-ins all satisfy it by shape. That matches how Python actually works, where code cares what a value can do rather than what it is.
Two more you will meet:
-> None is worth writing. It distinguishes a function that
does something from one that forgot to say what it gives back.
Annotate:
X | None. This is the highest-value
annotation there is.results = [] tells a checker
nothing.Do not bother with:
count: int = 0 says nothing the
0 did not.The measure is whether the annotation tells a reader or a
checker something it did not already know. photo: Photo = Photo() is noise; cache: dict[str, list[Photo]] = {} is
genuinely informative.
Your functions can now state what they expect and produce, a
tool can prove those statements consistent, and you know which
annotations earn their place. The single highest-value habit
from this lesson is annotating X | None returns — it turns the
most common runtime error in Python into a checker message.
Next is Dataclasses and Modelling Data, which builds
directly on this. Once you are describing types, the natural
next question is how to describe your own — and the answer is
usually not a hand-written class with five assignments in
__init__.
Before you move on, add hints to one module you already have and
run a checker over it. Pay attention to the first three errors:
in most codebases, at least one is a genuine bug rather than a
missing annotation, and usually it is a function that can return
None and a caller that never considered it.
BASICS
def f(x: str, n: int = 0) -> str:
count: int = 0
ignored at runtime - a checker verifies them, not Python
CONTAINERS
list[str] dict[str, int] set[str]
tuple[int, int] exactly two
tuple[str, ...] any number
EITHER, AND MAYBE-NOTHING
Photo | None <- the highest-value hint there is
int | str
width: int | None = None
ABSTRACT - from collections.abc
Iterable[int] anything you can loop over <- for parameters
Sequence[int] indexable and has a length
Callable[[Photo], Photo] takes a Photo, returns a Photo
TYPING MODULE
Any stops checking - contagious, avoid
Protocol "anything with these methods"
TypedDict a dict with known keys
Self returns my own type
-> None does something, returns nothing
THE RULE
accept the broadest type the body handles
return the most specific type you produce
WORTH ANNOTATING
public functions, anything returning X | None,
empty containers, non-obvious constants
NOT WORTH IT
obvious locals, tiny private helpers, test functions
RUNNING IT
mypy src/ or pyright
in CI, on every change
start lenient, tighten per module
hints nobody checks become confidently wrongnames: list[str]
sizes: dict[str, int] # keys are str, values are int
point: tuple[int, int] # exactly two ints
row: tuple[str, ...] # any number of strs
tags: set[str]def find(name: str) -> Photo | None:
...def resize(photo: Photo, width: int | None = None) -> Photo:
...from collections.abc import Iterable, Sequence, Callable
def total(sizes: Iterable[int]) -> int:
return sum(sizes)def apply(photos: list[Photo],
transform: Callable[[Photo], Photo]) -> list[Photo]:
return [transform(p) for p in photos]def total_size(photos: list[Photo]) -> Any:
return sum(p.size for p in photos)def total_size(photos: Iterable[Photo]) -> int:
return sum(p.size for p in photos)def load_config(path: str) -> Any: # returns parsed JSON
...
config = load_config("config.json")
timeout = config.timeuot # no error. Typo ships.from typing import TypedDict
class Config(TypedDict):
output_folder: str
limit: int
def load_config(path: str) -> Config:
...from typing import Protocol
class Sized(Protocol):
def __len__(self) -> int: ...
def describe(thing: Sized) -> str:
return f"{len(thing)} items"from typing import Self
class Photo:
def resized(self, width: int) -> Self: # returns my own type
...def process(paths: list[str]) -> None: # returns nothing
...