Step 05 of 13
Type hints
Annotate Python code with types, check it with mypy or Pyright, and use unions, generics, TypedDict, Literal and Protocol to catch bugs before running.
Python doesn't make you declare types, which makes small scripts quick to write. In larger code, the cost appears: you call a function and have to read its body to learn what it accepts and returns, and a None slips through to a place that can't handle it, crashing in production on a path no test covered. Type hints let you write down what types you expect, so your editor and a type checker can catch those mistakes before the code runs. This lesson covers the annotations you'll use every day, running a checker, and the more advanced tools: generics, TypedDict, Literal and Protocol.
Annotations and what they do
Annotate parameters, return values and, where it helps, variables:
def greet(name: str, times: int = 1) -> str:
return ", ".join([f"Hello, {name}"] * times)
count: int = 0
names: list[str] = []Two facts about hints matter more than any syntax:
- Python ignores them at runtime.
greet(42)still runs. Hints are information for tools and people, not checks. - A type checker reads them and reports code that can't be right, without running it. Your editor uses the same information for autocompletion and inline errors.
Hints are gradual: unannotated code is treated as "anything goes", so you can add them to an existing project one function at a time.
Running a type checker
The two main checkers are mypy and Pyright (which powers VS Code's Pylance). Add one to your project:
uv add --dev mypy
uv run mypy srcGiven this code:
def find_user(users: dict[str, str], user_id: str) -> str | None:
return users.get(user_id)
def shout(text: str) -> str:
return text.upper()
shout(find_user({"u1": "ana"}, "u2"))mypy reports:
src/app.py:9: error: Argument 1 to "shout" has incompatible type "str | None"; expected "str" [arg-type]
Found 1 error in 1 file (checked 1 source file)That's the bug that would otherwise surface as AttributeError: 'NoneType' object has no attribute 'upper' when a user isn't found. Run the checker in CI, and turn on strict mode as the codebase allows:
[tool.mypy]
strict = trueThe everyday types
Built-in collections take their element types in square brackets (Python 3.9+), and unions use | (3.10+):
def average(values: list[float]) -> float: ...
def word_counts(text: str) -> dict[str, int]: ...
def bounds(values: list[int]) -> tuple[int, int]: ... # exactly two ints
def tags(post_id: int) -> set[str]: ...
def parse_port(text: str) -> int | None: ... # an int, or None
def to_number(value: int | float | str) -> float: ...X | None is the most important one: it forces every caller to handle the missing case before using the value. The checker understands narrowing, so after a check it knows the None is gone:
port = parse_port(raw)
if port is None:
raise ValueError(f"invalid port: {raw!r}")
print(port + 1) # fine: port is int hereFor parameters, prefer the abstract types from collections.abc, which say what you need rather than what you happen to pass:
from collections.abc import Callable, Iterable, Mapping, Sequence
def total(prices: Iterable[float]) -> float: # any iterable: list, tuple, generator
return sum(prices)
def first_two(items: Sequence[str]) -> list[str]: # needs indexing and len
return list(items[:2])
def apply(values: list[int], fn: Callable[[int], int]) -> list[int]:
return [fn(v) for v in values]Accepting Iterable[float] instead of list[float] lets callers pass a generator or a tuple. Return concrete types (list, dict) so callers know exactly what they get.
Type aliases and NewType
Give complex or meaningful types a name with the type statement (Python 3.12+):
type Json = dict[str, "Json"] | list["Json"] | str | int | float | bool | None
type Matrix = list[list[float]]When two values share a representation but must not be mixed up, such as user IDs and order IDs that are both strings, NewType makes them distinct to the checker at no runtime cost:
from typing import NewType
UserId = NewType("UserId", str)
OrderId = NewType("OrderId", str)
def cancel(order_id: OrderId) -> None: ...
uid = UserId("u_42")
cancel(uid) # error: Argument 1 has incompatible type "UserId"; expected "OrderId"Literal and TypedDict: precise shapes
Literal restricts a value to specific constants, which is handy for modes and options:
from typing import Literal
def export(rows: list[dict], fmt: Literal["csv", "json"] = "csv") -> str: ...
export(rows, fmt="xml") # error: Argument "fmt" has incompatible type "Literal['xml']"TypedDict describes dictionaries with known keys, which is exactly what parsed JSON usually is:
from typing import NotRequired, TypedDict
class UserPayload(TypedDict):
id: int
email: str
nickname: NotRequired[str]
def display_name(user: UserPayload) -> str:
return user.get("nickname") or user["email"]
display_name({"id": 1, "emal": "[email protected]"}) # error: missing key "email", extra key "emal"A TypedDict is still a plain dict at runtime: it documents and checks shape without converting anything. If the data comes from outside your program (an HTTP request, a file), the checker can't know it's right; validate it at runtime with a library such as Pydantic, which you'll use in the FastAPI lesson.
Generics: functions that keep types
Some functions work for any type, but the output type depends on the input. A type parameter expresses that link. Python 3.12 added a compact syntax:
def first[T](items: Sequence[T]) -> T | None:
return items[0] if items else None
n = first([3, 1, 2]) # checker knows n is int | None
s = first(["a", "b"]) # str | NoneClasses can be generic too:
class Stack[T]:
def __init__(self) -> None:
self._items: list[T] = []
def push(self, item: T) -> None:
self._items.append(item)
def pop(self) -> T:
return self._items.pop()
ints = Stack[int]()
ints.push(1)
ints.push("two") # error: Argument 1 to "push" of "Stack" has incompatible type "str"; expected "int"Bound a type parameter when the function needs some capability: def largest[T: (int, float)](xs: list[T]) -> T accepts lists of int or of float.
Protocol: duck typing, checked
Python code often relies on duck typing: "if it has a read method, I can use it". Protocol describes such a shape, and any class with matching methods satisfies it, without inheriting from anything:
from typing import Protocol
class Notifier(Protocol):
def send(self, to: str, message: str) -> None: ...
class EmailNotifier: # no inheritance needed
def send(self, to: str, message: str) -> None:
print(f"email to {to}: {message}")
class FakeNotifier:
def __init__(self) -> None:
self.sent: list[tuple[str, str]] = []
def send(self, to: str, message: str) -> None:
self.sent.append((to, message))
def confirm_order(notifier: Notifier, customer: str) -> None:
notifier.send(customer, "Your order is confirmed")
confirm_order(EmailNotifier(), "[email protected]")
confirm_order(FakeNotifier(), "[email protected]")This is Python's equivalent of Go's interfaces: the consumer defines the shape it needs, and anything that fits works, including test fakes.
Practice
Here's an untyped module with a bug. Add type hints to everything, run mypy in strict mode, and fix what it finds.
# inventory.py
def load(rows):
stock = {}
for row in rows:
stock[row["sku"]] = int(row["qty"])
return stock
def lookup(stock, sku):
return stock.get(sku)
def reorder_list(stock, threshold=5):
return [sku for sku, qty in stock.items() if qty < threshold]
def describe(stock, sku):
qty = lookup(stock, sku)
return sku + ": " + str(qty) + " left" + (" (reorder!)" if qty < 5 else "")
print(describe(load([{"sku": "PEN", "qty": "3"}]), "PENCIL"))Use a TypedDict for the rows.
Show solution
# inventory.py
from collections.abc import Iterable
from typing import TypedDict
class Row(TypedDict):
sku: str
qty: str # CSV-style input: quantities arrive as text
type Stock = dict[str, int]
def load(rows: Iterable[Row]) -> Stock:
return {row["sku"]: int(row["qty"]) for row in rows}
def lookup(stock: Stock, sku: str) -> int | None:
return stock.get(sku)
def reorder_list(stock: Stock, threshold: int = 5) -> list[str]:
return [sku for sku, qty in stock.items() if qty < threshold]
def describe(stock: Stock, sku: str) -> str:
qty = lookup(stock, sku)
if qty is None:
return f"{sku}: not stocked"
flag = " (reorder!)" if qty < 5 else ""
return f"{sku}: {qty} left{flag}"
print(describe(load([{"sku": "PEN", "qty": "3"}]), "PENCIL")) # PENCIL: not stockedBefore the fix, strict mypy reports:
inventory.py:23: error: Unsupported operand types for > ("int" and "None") [operator]
inventory.py:23: note: Left operand is of type "int | None"That's the real bug: looking up an unknown SKU returns None, and the original code crashed with TypeError: '<' not supported between instances of 'NoneType' and 'int'. Annotating lookup honestly as int | None forced the caller to handle the missing case. The type checker didn't find the bug by magic; writing the true return type did, and the checker made sure nobody forgot it.
Key takeaways
- Type hints document intent and let checkers such as mypy and Pyright catch errors before running; Python ignores them at runtime.
- Use built-in generics (
list[str],dict[str, int]) andX | None, and let narrowing (if x is None) prove the missing case is handled. - Accept abstract types (
Iterable,Sequence,Mapping,Callable) and return concrete ones. Literal,TypedDict,NewTypeandtypealiases make shapes and meanings precise; validate external data at runtime separately.- Use
def f[T]andclass C[T]for generics,Protocolfor checked duck typing, and keepAnyandcastrare.
Check yourself
Quiz
0/4 correct · 4 left
1.What happens when you call def area(width: float) -> float with area("wide") at runtime?
2.Why accept Iterable[float] rather than list[float] for a function that sums prices?
3.user_id and order_id are both strings, and passing one where the other is expected is a recurring bug. What helps the checker catch it?
4.A function takes any object with a send(to, message) method. How do you express that so test fakes qualify without inheriting from anything?
Finished reading?
Mark it complete to fill in your roadmap.