Level 4 · Pythonic

Type Hints for Real 🔬

Lesson 30 introduced the notation. This lesson is about the payoff: a checker that reads your whole program and finds the bug in the branch you never tested.

Recap, then onwards

def total(prices: list[float], discount: float = 0.0) -> float:
    """Sum prices, applying a discount fraction."""
    return round(sum(prices) * (1 - discount), 2)


print(total([10.0, 24.99], discount=0.1))
31.49

Optional, and the billion dollar mistake

def find_pirate(name: str, crew: dict[str, int]) -> int | None:
    """Return the pirate's insult count, or None if they are not aboard."""
    return crew.get(name)


crew = {"Guybrush": 8}

found = find_pirate("Guybrush", crew)
missing = find_pirate("LeChuck", crew)

print(found, missing)

# mypy will refuse this, because found might be None:
#   error: Unsupported operand types for + ("None" and "int")
if found is not None:
    print(found + 1)
8 None
9

int | None is the modern spelling of Optional[int]. Its value is that mypy then forces you to handle the None case before using the value. Tony Hoare, who invented the null reference in 1965, later called it his "billion dollar mistake"; optional types are the fix, and Python has them if you opt in.

Generics: functions that work with any type, honestly

def first[T](items: list[T]) -> T | None:
    """Return the first item, or None if empty. Python 3.12+ syntax."""
    return items[0] if items else None


print(first([1, 2, 3]))
print(first(["grog", "map"]))
print(first([]))
1
grog
None

The [T] says "this function works with some type T, and whatever goes in is what comes out". mypy then knows first([1,2,3]) is an int and first(["a"]) is a str. That is much more useful than Any, which switches checking off entirely.

from typing import TypeVar

T = TypeVar("T")


def first_old(items: list[T]) -> T | None:
    """The pre-3.12 spelling, which you will still see everywhere."""
    return items[0] if items else None


print(first_old(["still works"]))
still works

Protocol: duck typing that a checker can verify

from typing import Protocol


class Speaker(Protocol):
    """Anything with a speak() returning a string."""

    def speak(self) -> str: ...


class Duck:
    def speak(self) -> str:
        return "quack"


class Robot:
    def speak(self) -> str:
        return "beep"


def make_it_talk(thing: Speaker) -> str:
    return thing.speak()


print(make_it_talk(Duck()))
print(make_it_talk(Robot()))
quack
beep
INTERFACING[Formidable: Success]

This is the piece that makes typed Python feel like Python rather than Java. Neither Duck nor Robot inherits from Speaker. They have never heard of it.

The check is structural: does this class have a speak() that returns a str? If yes, it qualifies. You can even write a Protocol for a third-party class you cannot modify, and suddenly their objects satisfy your interface.

Literal and TypedDict: describing shapes precisely

from typing import Literal, TypedDict


class Pirate(TypedDict):
    name: str
    insults: int
    role: Literal["captain", "lookout", "cook"]


def describe(pirate: Pirate) -> str:
    return f"{pirate['name']} the {pirate['role']} ({pirate['insults']} insults)"


guy: Pirate = {"name": "Guybrush", "insults": 8, "role": "captain"}
print(describe(guy))

# mypy rejects both of these, at check time, without running anything:
#   {"name": "Otis", "insults": 2, "role": "admiral"}   <- not in the Literal
#   {"name": "Otis", "insults": 2}                      <- missing "role"
Guybrush the captain (8 insults)

TypedDict is how you type the dictionaries that come back from JSON APIs without converting them to classes. Literal restricts a value to an exact set, which is the type-level version of the enum idea from Lesson 33.

What mypy actually catches

def apply_discount(price: float, percent: int) -> float:
    return price * (1 - percent / 100)


def checkout(items: list[dict[str, float]]) -> float:
    total = 0.0
    for item in items:
        total += item["price"]
    return apply_discount(total, "10")      # <- a string, not an int
$ mypy shop.py
shop.py:10: error: Argument 2 to "apply_discount" has incompatible type
    "str"; expected "int"  [arg-type]
Found 1 error in 1 file (checked 1 source file)

That bug is in a code path that might only run at checkout, with a real customer, at the weekend. mypy found it in under a second, without running the program, without a test. That is the actual argument for typing: it is a test suite you get for free, covering every line, including the ones you forgot.

Adopting it gradually

from typing import Any


def legacy(data: Any) -> Any:
    """Any switches checking off. Useful as a staging post, not a destination."""
    return data


def modern(data: dict[str, int]) -> list[str]:
    return sorted(data)


print(modern({"b": 2, "a": 1}))
['a', 'b']

The workable order for adding types to an existing project:

  1. Turn mypy on with default settings. Untyped code is simply ignored, so it will report almost nothing at first.
  2. Type your function signatures, starting with the ones other modules call. Do not bother annotating every local variable; mypy infers those.
  3. Fix what it finds. A surprising number will be real.
  4. Ratchet strictness up one flag at a time, per module, in pyproject.toml. disallow_untyped_defs is the big one.
# pyproject.toml
[tool.mypy]
python_version = "3.13"
warn_return_any = true
warn_unused_ignores = true

# start strict only where you are ready
[[tool.mypy.overrides]]
module = "myapp.core.*"
disallow_untyped_defs = true

Where types are worth it, and where they are not

SituationVerdict
A library other people importYes, fully. The hints are your API documentation
An application with more than one contributorYes, on function boundaries
Data pipelines with complex structuresYes. TypedDict pays for itself immediately
A 30-line scriptNo. The overhead exceeds the benefit
A Jupyter notebook you will delete tomorrowNo
Code full of dynamic tricks and getattrSometimes impossible. Be honest and use Any
🎭 Hints are still not enforced at runtime

Nothing in this lesson stops total("nonsense") from running. If you need runtime validation, that is what Pydantic is for: it uses the same annotations to actually check data at the boundary, which is why it is the backbone of FastAPI. Types for your tools, Pydantic for your inputs.

Exercise 1

Type a real function

Add complete hints, including the awkward return type.

def group_by_role(crew):
    result = {}
    for member in crew:
        result.setdefault(member["role"], []).append(member["name"])
    return result
Reveal solution
from typing import TypedDict


class Member(TypedDict):
    name: str
    role: str


def group_by_role(crew: list[Member]) -> dict[str, list[str]]:
    """Map each role to the names of the people doing it."""
    result: dict[str, list[str]] = {}
    for member in crew:
        result.setdefault(member["role"], []).append(member["name"])
    return result


print(group_by_role([
    {"name": "Otis", "role": "lookout"},
    {"name": "Meathook", "role": "lookout"},
    {"name": "Elaine", "role": "captain"},
]))
{'lookout': ['Otis', 'Meathook'], 'captain': ['Elaine']}

The annotation on result is needed because mypy cannot infer the type of an empty dictionary. That is the one local variable you usually do have to annotate.

Exercise 2

Write a Protocol

Define a Saveable protocol for anything with save(path) and name, then write a function that backs up a list of them. Show two unrelated classes satisfying it.

Reveal solution
from typing import Protocol


class Saveable(Protocol):
    name: str

    def save(self, path: str) -> int: ...


class Document:
    def __init__(self, name: str, body: str) -> None:
        self.name = name
        self.body = body

    def save(self, path: str) -> int:
        return len(self.body)


class Image:
    def __init__(self, name: str, pixels: int) -> None:
        self.name = name
        self.pixels = pixels

    def save(self, path: str) -> int:
        return self.pixels * 3


def back_up(items: list[Saveable], folder: str) -> int:
    """Save everything, returning the total bytes written."""
    total = 0
    for item in items:
        written = item.save(f"{folder}/{item.name}")
        print(f"  {item.name}: {written} bytes")
        total += written
    return total


print("total:", back_up([Document("log.txt", "hello"), Image("map.png", 100)], "backup"))
  log.txt: 5 bytes
  map.png: 300 bytes
total: 305

Neither class inherits from anything, and no class knows the other exists. That is duck typing with a safety net.

Exercise 3

Find the type error by eye

mypy reports one error here. Where, and why?

def parse_scores(raw: str) -> dict[str, int]:
    scores = {}
    for line in raw.splitlines():
        name, value = line.split(":")
        scores[name.strip()] = value.strip()
    return scores
Reveal solution

The annotation promises dict[str, int] but value.strip() is a str, so the dictionary holds strings. Everything downstream that does arithmetic on those values would fail at runtime, probably far from here.

def parse_scores(raw: str) -> dict[str, int]:
    """Parse 'name: score' lines into a mapping of name to score."""
    scores: dict[str, int] = {}
    for line in raw.splitlines():
        name, value = line.split(":")
        scores[name.strip()] = int(value.strip())
    return scores


print(parse_scores("Guybrush: 95\nOtis: 42"))
{'Guybrush': 95, 'Otis': 42}

This is the single most common category of bug that typing catches in real codebases: a value that is a string where everyone assumed a number, usually arriving from user input, a CSV file or a JSON payload.

+100 XP