Advanced · Lesson 60 of 94

Static checking with mypy, Protocols, and generics

Watch this lesson

View full playlist ↗

Read the lesson

What you'll learn

Concept

mypy reads your hints and checks that every call, assignment, and return agrees with them, across every file, without running anything. It reports each disagreement with a file, a line, and a sentence. Run it before your tests and it catches a whole class of bugs, wrong argument order, a None that was not handled, a method called on the wrong type, in seconds rather than in production. The output is the tool's opinion, and when you disagree, you either fix the hint or fix the code; both are improvements.

Static checking with mypy, Protocols, and generics: source with hints → mypy → run tests, ship → fix the code or the hint
Static checking with mypy, Protocols, and generics · Figure 1 · Open for full size

The first thing a real codebase needs beyond the basics is a way to type dictionaries with known keys, which is what JSON gives you. TypedDict declares a dictionary shape: which keys exist and what type each value has. It is still a plain dict at runtime, with no overhead and no methods, but the checker now knows that user["name"] is a str and that user["nmae"] is a mistake. total=False or NotRequired[...] mark optional keys.

Tool Describes Checked how Runtime cost
TypedDict a dictionary with specific keys and value types statically none; it is a plain dict
Protocol "any object with these methods/attributes" statically (runtime with @runtime_checkable) none
generics [T] a function/class that works for any type, consistently statically none
Literal["a", "b"] one of a fixed set of values statically none

Protocol is duck typing made checkable. Declare a class with the methods and attributes you need, and any object that has them satisfies the protocol, with no inheritance and no import of your type by the other side. A function that takes Drawable accepts anything with a draw() method, and mypy verifies each caller's argument has one. This is the tool for describing what your function needs rather than what class it wants, and the standard library uses it throughout collections.abc.

Generics let types flow through code that does not care what it holds. def first(items: list[T]) -> T says "whatever the item type is, that is what comes back", so first(["a"]) is known to be a str and first([1]) an int. Since 3.12 the type parameter is declared in brackets after the name, def first[T](items: list[T]) -> T:, and classes take them the same way, class Stack[T]:. A bound, [T: Comparable], restricts what T may be. Before 3.12 this needed TypeVar, which you will still see in older code.

Static checking with mypy, Protocols, and generics: first(['a', 'b' → T = str → returns str → first([1, 2 → T = int → returns int → Stack[int → mypy: error, str is not int
Static checking with mypy, Protocols, and generics · Figure 2 · Open for full size

Code

A file with two deliberate type errors and its mypy report, then the fixed version and its clean report. The check runs the pinned mypy against the pinned Python.

# broken.py
def total(prices: list[float]) -> float:
    return sum(prices)


def greet(name: str | None) -> str:
    return "hello, " + name


print(total(["10", "5"]))
print(greet(None))
# check_broken.sh
mypy broken.py
Output
broken.py:6: error: Unsupported operand types for + ("str" and "None")  [operator]
broken.py:6: note: Right operand is of type "str | None"
broken.py:9: error: List item 0 has incompatible type "str"; expected "float"  [list-item]
broken.py:9: error: List item 1 has incompatible type "str"; expected "float"  [list-item]
Found 3 errors in 1 file (checked 1 source file)
# fixed.py
def total(prices: list[float]) -> float:
    return sum(prices)


def greet(name: str | None) -> str:
    return "hello, " + (name if name is not None else "stranger")


print(total([10.0, 5.0]))
print(greet(None))
Output
15.0
hello, stranger
# check_fixed.sh
mypy fixed.py
Output
Success: no issues found in 1 source file

TypedDict for JSON-shaped data and Protocol for a behavioural contract. Both are ordinary at runtime; the shell block shows mypy catching the misuse of each.

# shapes.py
from typing import NotRequired, Protocol, TypedDict


class User(TypedDict):
    id: int
    name: str
    email: NotRequired[str]


class Drawable(Protocol):
    def draw(self) -> str: ...


class Circle:
    def draw(self) -> str:
        return "()"


class Square:
    def draw(self) -> str:
        return "[]"


def render(shapes: list[Drawable]) -> str:
    return " ".join(s.draw() for s in shapes)


def display(user: User) -> str:
    return f"{user['id']}: {user['name']} <{user.get('email', 'no email')}>"


ada: User = {"id": 1, "name": "ada"}
print(display(ada))
print(render([Circle(), Square()]))
Output
1: ada <no email>
() []
# shapes_wrong.py
from shapes import User, render, display

bad_user: User = {"id": "one", "name": "ada"}
print(display({"id": 2, "nmae": "linus"}))
print(render(["not drawable"]))
# check_shapes.sh
mypy shapes_wrong.py
Output
shapes_wrong.py:3: error: Incompatible types (expression has type "str", TypedDict item "id" has type "int")  [typeddict-item]
shapes_wrong.py:4: error: Missing key "name" for TypedDict "User"  [typeddict-item]
shapes_wrong.py:4: error: Extra key "nmae" for TypedDict "User"  [typeddict-unknown-key]
shapes_wrong.py:5: error: List item 0 has incompatible type "str"; expected "Drawable"  [list-item]
Found 4 errors in 1 file (checked 1 source file)

Generics with the 3.12 syntax: a function whose return type follows its argument, and a Stack[T] class that mypy keeps consistent.

# generics.py
from collections.abc import Iterable


def first[T](items: Iterable[T]) -> T | None:
    for item in items:
        return item
    return None


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()

    def __len__(self) -> int:
        return len(self._items)


def largest[T: (int, float, str)](values: list[T]) -> T:
    return max(values)


s: Stack[int] = Stack()
s.push(1)
s.push(2)
print(s.pop(), len(s))
print(first(["a", "b"]), first([]), first(range(5, 9)))
print(largest([3, 9, 4]), largest(["pear", "fig"]))
Output
2 1
a None 5
9 pear

Try it yourself

Take typed_inventory.py from the previous lesson and run mypy on it; fix anything it reports. Then add a TypedDict for the JSON shape a product arrives in, a Protocol called Priced with a price attribute, a generic def cheapest[T: Priced](items: list[T]) -> T, and a deliberate mistake in a caller. Run mypy again, read the message, and fix it.

Common mistakes

What's next

Your code is checked before it runs. Next comes a tool for text that every developer needs and few enjoy: regular expressions, done carefully.

Course overview