Type Hints
Optional annotations that a checker enforces before your code runs. Python stays dynamic; you get most of the safety anyway.
Type hints are annotations Python itself ignores. A separate checker reads them and tells you when something cannot work.
def greet(name: str) -> str:
return f"Hello, {name}"
greet(42) # runs fine — Python does not checkuv run mypy app.py
# app.py:4: error: Argument 1 to "greet" has incompatible type "int"; expected "str"The value is entirely in that second step. Hints with no checker in CI are documentation that drifts.
The basics#
name: str = "ada"
count: int = 0
ratio: float = 0.5
active: bool = True
def total(items: list[int]) -> int:
return sum(items)
def lookup(data: dict[str, int], key: str) -> int | None:
return data.get(key)Modern syntax uses the built-in types directly — list[int], dict[str, int], tuple[int, str], set[str] — rather than importing List, Dict and friends from typing. Those older forms are deprecated; generated code still produces them because there is a decade of them in the training data.
from typing import List, Optional # old
def f(x: Optional[List[int]]) -> None: ...
def f(x: list[int] | None) -> None: ... # currentOptional and unions#
X | None is Python's answer to nullability, and annotating it is what makes the checker catch the most common runtime error:
def find_user(uid: str) -> User | None:
...
user = find_user("1")
print(user.email) # error: Item "None" of "User | None" has no attribute "email"
if user is not None:
print(user.email) # fine — narrowedThat narrowing works with is None, isinstance, and truthiness, exactly as a reader would expect.
Functions and callables#
from collections.abc import Callable, Iterable, Iterator, Sequence
def apply(values: Iterable[int], fn: Callable[[int], str]) -> list[str]:
return [fn(v) for v in values]
def chunks(data: Sequence[int], size: int) -> Iterator[list[int]]:
for i in range(0, len(data), size):
yield data[i : i + size]Two habits worth adopting:
- Accept the widest type you can, return the narrowest. Take
Iterable[int]if you only iterate; return a concretelist[int]so callers can index it. - Import ABCs from
collections.abc, nottyping— thetypingversions are deprecated aliases.
Protocols — typing by shape#
Python's duck typing has a type-level equivalent. A Protocol says "anything with these methods", with no inheritance required:
from typing import Protocol
class SupportsClose(Protocol):
def close(self) -> None: ...
def cleanup(resource: SupportsClose) -> None:
resource.close()
cleanup(open("x.txt")) # a file has .close() — accepted
cleanup(my_connection) # so does this — acceptedThis is how you type an interface without forcing every implementation to inherit from your base class. It is the most Pythonic part of the type system and the most under-used.
TypedDict#
For dictionaries with a known shape — a JSON payload, a config:
from typing import TypedDict, NotRequired
class UserRecord(TypedDict):
id: str
email: str
age: NotRequired[int] # may be absent
def send(user: UserRecord) -> None:
print(user["email"])
print(user["nope"]) # error: no key "nope"Useful when the data genuinely is a dict. If you control the shape, a dataclass is usually better — attribute access, defaults and validation all come free.
Generics#
def first[T](items: list[T]) -> T | None:
return items[0] if items else None
reveal_type(first([1, 2, 3])) # int | None
reveal_type(first(["a"])) # str | NonePython 3.12 introduced this inline syntax; earlier versions need an explicit TypeVar. reveal_type() is a checker-only helper that prints the inferred type — invaluable when a hint is not doing what you expected.
Setting up the checker#
[tool.mypy]
python_version = "3.12"
strict = true
warn_unused_ignores = true
files = ["src", "tests"]
# existing debt, opted out module by module and deleted as it is paid down
[[tool.mypy.overrides]]
module = ["myapp.legacy.*"]
ignore_errors = trueuv run mypy srcOn an existing codebase, strict = true produces a lot of errors at once. The override block above is the way through: get to green immediately, then delete entries one at a time. A visibly shrinking list beats an invisible surrender.
Where hints do not help#
Annotations are erased at runtime, so they constrain your code and not your data:
def load(raw: str) -> dict[str, int]:
return json.loads(raw) # could be anything at runtime; the hint is a claimFor data arriving from outside your program — a request body, a config file, a database row — use a validating library (pydantic, attrs with validators) so something actually checks. A hint is a promise; a parse is a verification.
Exercise#
# Add type hints to all of these, then reason about what mypy would say:
def parse_scores(raw):
return {name: int(value) for name, value in (p.split("=") for p in raw.split(","))}
def best(scores, minimum=0):
winner = None
for name, value in scores.items():
if value >= minimum and (winner is None or value > scores[winner]):
winner = name
return winner
print(best(parse_scores("ada=90,alan=85,grace=95"), 80))Common questions#
Do type hints slow Python down?#
No — they are ignored at runtime beyond being stored in __annotations__. The only cost is running the checker, which happens at development time and in CI, not in production.
Should I annotate everything?#
Annotate function signatures, especially public ones — they are the contract, and the checker uses them at every call site. Local variables usually need no annotation because inference handles them; add one only when the checker cannot work it out or a reader would struggle.
mypy or pyright?#
pyright is faster and stricter by default and powers the VS Code experience; mypy is the reference implementation with better tooling for incremental adoption on a legacy codebase. Either is fine. Running both is not worth the friction.
Get the Python agent pack
A battle-tested AGENTS.md, the review checklist, and the failure-mode cheat sheet for Python. One email, then occasional updates when the tooling shifts. No course pitch.
AGENTS.md now — no email needed.