Type Hints and Modern Python
Type hints are annotations the interpreter stores and ignores. They exist for the reader, the editor and a type checker you run separately. Used well they are documentation that cannot go stale; used badly they are noise.
Nothing checks this
def double(n: int) -> int:
return n * 2
print(double("abc")) # 'abcabc' - no error, no warning
print(double.__annotations__) # {'n': <class 'int'>, 'return': <class 'int'>}
Open this example in the editor and change it. Nothing you type is saved.
The annotation is a claim, not a guard. mypy or pyright reads it and tells you double("abc") is wrong, before you run anything. Python itself does not.
The annotations are readable at run time if you want them, which is how dataclasses, pydantic and FastAPI work - they inspect the annotations and generate behaviour from them.
Writing the common shapes
from collections.abc import Callable, Iterable, Iterator, Mapping, Sequence
def first(values: Sequence[int]) -> int | None:
return values[0] if values else None
def totals(rows: Iterable[Mapping[str, int]]) -> dict[str, int]:
out: dict[str, int] = {}
for row in rows:
for key, value in row.items():
out[key] = out.get(key, 0) + value
return out
def apply(values: list[int], fn: Callable[[int], str]) -> list[str]:
return [fn(v) for v in values]
print(first([1, 2]), first([]))
print(totals([{"a": 1}, {"a": 2, "b": 3}])) # {'a': 3, 'b': 3}
print(apply([1, 2], str)) # ['1', '2']
Open this example in the editor and change it. Nothing you type is saved.
str | None replaces Optional[str]; list[int] replaces List[int]. The typing module's capitalised aliases are deprecated and you should not write new code with them.
The important distinction is what to accept and what to return:
def good(values: Iterable[int]) -> list[int]:
return sorted(values)
Open this example in the editor and change it. Nothing you type is saved.
Accept the most general thing you can use - Iterable, Sequence, Mapping - so a caller can pass a list, a tuple, a generator or a dict view. Return the most specific thing you actually produce - list, dict - so the caller knows what they can do with it. Accepting list[int] when you only iterate it needlessly rejects a generator.
Generics, protocols and literals
A generic function keeps the relationship between argument and result:
from typing import TypeVar
from collections.abc import Sequence
T = TypeVar("T")
def first_or(values: Sequence[T], default: T) -> T:
return values[0] if values else default
print(first_or([1, 2], 0)) # a checker knows this is an int
print(first_or(["a"], "z")) # and this is a str
Open this example in the editor and change it. Nothing you type is saved.
Without the TypeVar you would have to annotate the return as object, and every caller would have to cast it back.
A Protocol is structural: anything with the right shape satisfies it, with no inheritance and no registration.
from typing import Protocol, runtime_checkable
@runtime_checkable
class Closeable(Protocol):
def close(self) -> None: ...
class Connection:
def close(self) -> None:
print("closed")
class Unrelated:
pass
def shut(resource: Closeable) -> None:
resource.close()
shut(Connection()) # 'closed' - it just fits
print(isinstance(Connection(), Closeable)) # True
print(isinstance(Unrelated(), Closeable)) # False
Open this example in the editor and change it. Nothing you type is saved.
Connection never heard of Closeable. That is the point: protocols describe duck typing in a way a checker can verify, which is what Python needed rather than Java's interfaces.
Literal narrows to specific values; TypedDict describes a dict shape:
from typing import Literal, TypedDict
class User(TypedDict):
id: int
name: str
role: Literal["admin", "editor", "viewer"]
def promote(user: User, to: Literal["admin", "editor"]) -> User:
return {**user, "role": to}
u: User = {"id": 1, "name": "Aisha", "role": "viewer"}
print(promote(u, "editor"))
# {'id': 1, 'name': 'Aisha', 'role': 'editor'}
# promote(u, "owner") - a checker rejects this; Python would not
Open this example in the editor and change it. Nothing you type is saved.
A TypedDict is a plain dict at run time. It costs nothing and describes the JSON you actually receive, which a dataclass cannot do without a conversion step.
Narrowing, and the checker's view of control flow
def describe(value: int | str | None) -> str:
if value is None:
return "nothing" # here, value is None
if isinstance(value, int):
return f"the number {value + 1}" # here, it is int - + 1 is allowed
return value.upper() # here, it must be str
print(describe(None), describe(1), describe("hi")) # nothing the number 2 HI
Open this example in the editor and change it. Nothing you type is saved.
A checker follows is None, isinstance, assert and early returns, and narrows the type in each branch. That is why the value.upper() at the end needs no cast: there is nothing else it could be.
When the checker cannot see what you know, assert is the honest tool - it narrows and it checks at run time:
def load(mapping: dict[str, str], key: str) -> int:
raw = mapping.get(key)
assert raw is not None, f"{key} must be set"
return int(raw)
print(load({"port": "5432"}, "port")) # 5432
Open this example in the editor and change it. Nothing you type is saved.
typing.cast also narrows, but it is a lie to the checker with no run-time effect. Use it only at a boundary where you genuinely know more than the checker can.
A worked example
from collections.abc import Iterable, Sequence
from dataclasses import dataclass
from typing import Literal, Protocol, TypedDict, TypeVar
Status = Literal["draft", "published", "archived"]
class LessonRow(TypedDict):
"""The shape of a row as it arrives from the database driver."""
id: int
title: str
status: Status
minutes: int
@dataclass(frozen=True, slots=True)
class Lesson:
id: int
title: str
status: Status
minutes: int
@classmethod
def from_row(cls, row: LessonRow) -> "Lesson":
return cls(id=row["id"], title=row["title"],
status=row["status"], minutes=row["minutes"])
class HasMinutes(Protocol):
"""Structural: anything with a minutes attribute will do, whether
or not it is a Lesson."""
minutes: int
T = TypeVar("T")
def total_minutes(items: Iterable[HasMinutes]) -> int:
return sum(item.minutes for item in items)
def take(values: Sequence[T], count: int) -> list[T]:
"""Generic, so the element type survives the call."""
return list(values[:count])
def published(lessons: Iterable[Lesson]) -> list[Lesson]:
return [lesson for lesson in lessons if lesson.status == "published"]
ROWS: list[LessonRow] = [
{"id": 1, "title": "Selectors", "status": "published", "minutes": 7},
{"id": 2, "title": "The box model", "status": "published", "minutes": 7},
{"id": 3, "title": "Container queries", "status": "draft", "minutes": 6},
{"id": 4, "title": "Old notes", "status": "archived", "minutes": 3},
]
lessons = [Lesson.from_row(row) for row in ROWS]
live = published(lessons)
print(len(live), total_minutes(live)) # 2 14
print([lesson.title for lesson in take(live, 1)]) # ['Selectors']
# The protocol accepts something that is not a Lesson at all.
@dataclass
class Quiz:
minutes: int
questions: int
print(total_minutes([Quiz(5, 10), Quiz(3, 6)])) # 8
print(total_minutes(lessons + [])) # 23
# Narrowing in practice.
def summarise(item: Lesson | Quiz | None) -> str:
if item is None:
return "nothing"
if isinstance(item, Quiz):
return f"quiz, {item.questions} questions"
return f"lesson {item.id}: {item.title}"
for item in (None, Quiz(5, 10), lessons[0]):
print(summarise(item))
# nothing
# quiz, 10 questions
# lesson 1: Selectors
# And the reminder that none of this is enforced at run time.
bad: LessonRow = {"id": "not an int", "title": "x", "status": "nope", "minutes": 1}
print(Lesson.from_row(bad))
# Lesson(id='not an int', title='x', status='nope', minutes=1)
# A checker rejects that line. Python builds it happily.
Open this example in the editor and change it. Nothing you type is saved.
The final block is the honest ending. Two deliberate type errors, no complaint from the interpreter, an object built with a string where an integer belongs. Hints find that at the point you write it, in your editor, for free - but only if you actually run a checker.
Where hints earn their place
Not everywhere. The cost is real: annotations add noise, they go stale if nothing checks them, and a complex generic signature can be harder to read than the function it describes.
Where they pay:
- Function signatures at module boundaries. Anything another file calls.
- Anything returning
Nonesometimes.int | Noneis the single most useful hint there is, because it forces the caller to think about the empty case. - Data shapes.
TypedDictfor JSON,dataclassfor domain objects. - Containers whose contents are not obvious.
dict[str, list[int]]saves a reader from scrolling.
Where they do not:
- Short local variables.
count = 0needs no: int. - Obvious
self. Never annotate it. - Throwaway scripts that nobody will maintain.
Run the checker in CI. A hint nobody checks is a comment that is allowed to lie, which is worse than no comment at all.
When it goes wrong
| Symptom | Cause |
|---|---|
| A wrong type caused no error | Hints are not enforced; run a checker |
TypeError: 'type' object is not subscriptable |
list[int] on a very old Python |
NameError annotating a class inside itself |
Quote it, or from __future__ import annotations |
| A checker rejects a generator argument | Parameter says list; use Iterable |
Optional[X] still allows None after a check |
The check is not one the checker narrows on |
isinstance with a Protocol raised |
It needs @runtime_checkable |
| Annotations slowed down imports | Heavy imports for types; put them under TYPE_CHECKING |
cast did not convert anything |
It never does; it only tells the checker |
A check you can run
Take any function in your codebase that sometimes returns None and annotate its return type as -> X | None. Then run mypy on the file.
Every caller that uses the result without checking for None is now an error, listed with a line number. That list is not noise - it is the set of places your program will raise AttributeError: 'NoneType' object has no attribute ... given the wrong input. One annotation, and the checker found all of them without running the code.