Index Ai Academy
Phase 7 · Software engineering · Python in depth

Typing in depth

Generics, protocols, TypedDict, Literal and narrowing, checked by mypy.

About 45 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

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

  1. Running mypy
  2. Optional values and narrowing
  3. Precise shapes: Literal, TypedDict and Protocol
  4. Generics: one function, many types
  5. A Protocol for notifiers
  6. Adding types to an existing codebase
  7. 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"
'''))
It runs right here in your browser, with nothing to install.

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.

Quick check
What does mypy report for this code? (The cell prints its first error's code.)
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")
  1. attr-defined
  2. return-value
  3. clean
  4. arg-type
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: ...
  • Literal limits a value to a fixed set. Passing "Portland" (capital P) is now a type error, caught before it ever reaches the database.
  • TypedDict describes a dictionary's keys and their types: the shape of a JSON record. row["quantity"] is reported as an unknown key, and row["qty"] is known to be an int.
  • Protocol describes behavior instead of ancestry. Any class with a matching send method counts as a Notifier, 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.
Try it now

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.

Drill

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")
In the lesson, your code is checked as you write it.

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:

  1. Start with mypy's default settings, which check only annotated functions, and fix what it finds.
  2. Annotate the edges first: functions other modules call, data that comes from files and APIs.
  3. Turn on stricter options file by file, until --strict passes for the whole project.
  4. 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

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)
In the lesson you can change this and run it.

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

Create a free account

Try a quiz question

Why does mypy report item.qty when item comes from STOCK.get(sku)?

  1. get can return None, and None has no qty, so the code must check first
  2. get always returns None
  3. qty isn't annotated
  4. Dictionaries can't hold dataclasses

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson