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.

17 min 9 sections

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:

python
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:

  1. Python ignores them at runtime. greet(42) still runs. Hints are information for tools and people, not checks.
  2. 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:

bash
uv add --dev mypy
uv run mypy src

Given this code:

python
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:

text
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:

toml
[tool.mypy]
strict = true

The everyday types

Built-in collections take their element types in square brackets (Python 3.9+), and unions use | (3.10+):

python
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:

python
port = parse_port(raw)
if port is None:
    raise ValueError(f"invalid port: {raw!r}")
print(port + 1)     # fine: port is int here

For parameters, prefer the abstract types from collections.abc, which say what you need rather than what you happen to pass:

python
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+):

python
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:

python
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:

python
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:

python
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:

python
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 | None

Classes can be generic too:

python
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:

python
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.

python
# 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
python
# 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 stocked

Before the fix, strict mypy reports:

text
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]) and X | 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, NewType and type aliases make shapes and meanings precise; validate external data at runtime separately.
  • Use def f[T] and class C[T] for generics, Protocol for checked duck typing, and keep Any and cast rare.

Check yourself

Quiz

0/4 correct · 4 left

  1. 1.What happens when you call def area(width: float) -> float with area("wide") at runtime?

  2. 2.Why accept Iterable[float] rather than list[float] for a function that sums prices?

  3. 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. 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.