Typing in depth
Generics, protocols, TypedDict, Literal and narrowing, checked by mypy.
Create a free account Try part of it here
Why it matters
The types lesson introduced type hints and dataclasses. On their own, hints are documentation; a type checker turns them into a safety net that reads the whole program and reports mistakes before it runs: the None nobody checked, the string passed where a number was expected, the misspelled dictionary key. On a large codebase that's hundreds of bugs caught without writing a test. Type hints also power your editor's autocomplete, and libraries like FastAPI and Pydantic read them to validate data.
What you'll be able to do
- Run mypy and read its errors, and use reveal_type to see what it thinks a value is
- Narrow optional and union types with checks, so mypy and readers both know what's safe
- Describe shapes precisely with Literal, TypedDict, Protocol and generics
- Add types to existing code gradually, and use them to find real bugs
What it covers
- Running mypy
- Optional values and narrowing
- Precise shapes: Literal, TypedDict and Protocol
- Generics: one function, many types
- A Protocol for notifiers
- Adding types to an existing codebase
- Worked example: types that find a bug
Running mypy
mypy reads your code, follows the type hints, and reports every place where the types don't fit. It never runs the program. Here it runs in the browser on a small file:
from mypy import api def check_types(code): with open("check.py", "w") as f: f.write(code) report, _, status = api.run(["check.py", "--strict", "--no-incremental", "--cache-dir=/dev/null"]) return report.strip() print(check_types(''' def with_tax(price: float, rate: float = 0.08) -> float: return round(price * (1 + rate), 2) total: float = with_tax(120) label: str = with_tax(120) count: int = len("TE-101") + "1" '''))
Each line names the file, the line number and the problem, with an error code in brackets. --strict turns on the strictest checks, including "every function must be annotated". On your own computer you'd install it with pip and run mypy --strict yourmodule.py, or let your editor run it as you type.
mypy can't see values, only types. It knows with_tax returns a float, so assigning that to a str variable is an error, whatever number it would actually return. That's the trade: it can't tell you a total is wrong, but it can prove whole kinds of mistakes are impossible.
Optional values and narrowing
Many bugs come from a value that might be None. dict.get, re.match and database lookups all return "something or nothing". The type Item | None (older code writes Optional[Item]) says so, and mypy then refuses to let you use it as an Item until you've checked:
from mypy import api def check_types(code): open("check.py", "w").write(code) return api.run(["check.py", "--strict", "--no-incremental", "--cache-dir=/dev/null"])[0].strip() print(check_types(''' from dataclasses import dataclass @dataclass class Item: sku: str qty: int STOCK = {"TE-101": Item("TE-101", 4)} def careless(sku: str) -> int: item = STOCK.get(sku) return item.qty def careful(sku: str) -> int: item = STOCK.get(sku) if item is None: return 0 reveal_type(item) return item.qty '''))
In careless, item might be None, and None has no qty, so mypy reports it. That's exactly the AttributeError that would crash at 2 a.m. for an unknown SKU. In careful, the if item is None: return narrows the type: after the check, mypy knows item is an Item. reveal_type(x) asks mypy what it thinks a type is at that point, which is handy when an error puzzles you. (Remove it afterwards; it isn't a real function.) isinstance checks narrow unions the same way.
from mypy import api open("c.py", "w").write("def first(xs: list[str]) -> str:\n return xs[0] if xs else None\n") out = api.run(["c.py", "--no-incremental", "--cache-dir=/dev/null"])[0] print(out.split("[")[1].split("]")[0] if "[" in out else "clean")
Show the answer
return-value
The function promises to return str, but returns None for an empty list. mypy reports an incompatible return value; the honest type would be str | None.
Precise shapes: Literal, TypedDict and Protocol
Plain str and dict say too little. A few typing tools describe data more exactly:
from typing import Literal, Protocol, TypedDict Store = Literal["portland", "seattle", "denver", "online"] # only these four strings class OrderRow(TypedDict): # a dictionary with exactly these keys and value types sku: str qty: int store: Store class Notifier(Protocol): # anything with a send(text) method fits def send(self, text: str) -> None: ...
Literallimits a value to a fixed set. Passing"Portland"(capital P) is now a type error, caught before it ever reaches the database.TypedDictdescribes a dictionary's keys and their types: the shape of a JSON record.row["quantity"]is reported as an unknown key, androw["qty"]is known to be anint.Protocoldescribes behavior instead of ancestry. Any class with a matchingsendmethod counts as aNotifier, without inheriting from anything. It's how Python's "duck typing" ("if it quacks like a duck") gets checked: the email sender, the SMS sender and a test fake all fit, and none needs to know about the others.
Choose the most precise type that's still true. Precise types catch more mistakes, but a type that lies is worse than none.
Generics: one function, many types
Some functions work the same way for any type, but their output type depends on their input: the first item of a list of strings is a string, and of a list of orders, an order. A type variable says so. Python 3.12 added a short syntax with square brackets after the name:
from mypy import api def check_types(code): open("check.py", "w").write(code) return api.run(["check.py", "--strict", "--no-incremental", "--cache-dir=/dev/null"])[0].strip() print(check_types(''' def first[T](items: list[T], default: T) -> T: return items[0] if items else default class Box[T]: def __init__(self, item: T) -> None: self.item = item def get(self) -> T: return self.item sku: str = first(["TE-101", "PA-108"], "") qty: int = first([3, 5], 0) bad: int = first(["TE-101"], "") crate = Box(12) reveal_type(crate.get()) '''))
T stands for "whatever type the caller uses". Called with a list of strings, first returns a string, so assigning it to an int is an error. Box[T] is a generic class, like list[int] and dict[str, float] in the standard library, and mypy works out Box[int] from the argument. Older code spells the same idea T = TypeVar("T"); you'll see both.
A Protocol for notifiers
Define a Notifier Protocol with send(self, text: str) -> None, and annotate alert_all to take a list[Notifier]. The Email and Sms classes don't inherit from it, but should still fit. The checks run mypy --strict on your cell.
from typing import Protocol class Email: def __init__(self) -> None: self.sent: list[str] = [] def send(self, text: str) -> None: self.sent.append("email: " + text) class Sms: def __init__(self) -> None: self.sent: list[str] = [] def send(self, text: str) -> None: self.sent.append("sms: " + text[:160]) def alert_all(channels, text): for channel in channels: channel.send(text) alert_all([Email(), Sms()], "Rain at 8:00")
Adding types to an existing codebase
You rarely start with fully typed code. Typing is gradual: unannotated code is treated as Any, a type that fits everything, so mypy stays quiet about it. You add types where they pay off most, and tighten the settings as you go:
- Start with mypy's default settings, which check only annotated functions, and fix what it finds.
- Annotate the edges first: functions other modules call, data that comes from files and APIs.
- Turn on stricter options file by file, until
--strictpasses for the whole project. - Run mypy in CI, so new code can't slip back.
Each newly annotated function tends to expose a real bug or two. That's the point: the bugs were always there, waiting for the unlucky input. When a library you use has no type information, mypy treats it as Any; many popular libraries ship hints, and others have a separate types-... package.
Use Any and # type: ignore[code] sparingly, and always with the error code, like noqa in linting. Each one switches the safety net off for that spot. And never let types replace tests: a function can be perfectly typed and still compute the wrong total.
Worked example: types that find a bug
Annotate a small pricing module and let mypy point at the line that would have crashed.
from mypy import api MODULE = ''' from typing import Literal, TypedDict Tier = Literal["basic", "club"] class Line(TypedDict): sku: str price: float qty: int DISCOUNT: dict[Tier, float] = {"basic": 0.0, "club": 0.1} def line_total(line: Line) -> float: return line["price"] * line["quantity"] def order_total(lines: list[Line], tier: Tier) -> float: subtotal = sum(line_total(l) for l in lines) return round(subtotal * (1 - DISCOUNT[tier]), 2) print(order_total([{"sku": "TE-101", "price": 200.0, "qty": 1}], "Club")) ''' open("pricing.py", "w").write(MODULE) report, _, status = api.run(["pricing.py", "--strict", "--no-incremental", "--cache-dir=/dev/null"]) print(report)
Two real bugs, found without running anything. line["quantity"] uses a key the Line TypedDict doesn't have: at run time that's a KeyError on the first order. And "Club" with a capital C isn't one of the Tier literals: at run time it's a KeyError in the discount table. Neither would show up until a customer hit it. (The "Returning Any" message is a knock-on effect of the bad key, and disappears once it's fixed.) With precise types, mypy finds both in about a second, and the error messages name the exact lines to fix.
Practice what you just read
- 5 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 5 review cards for spaced repetition
Try a quiz question
Why does mypy report item.qty when item comes from STOCK.get(sku)?