Module 7 ended with a shop that no longer crashes: it explains itself. But to explain itself, Papyrus relied on docstrings, guard clauses and your memory: does sell() return the amount or the Book? Can find_book() return None? Until now the answer lived in your head or in a comment. Type hints write those contracts directly into the function signature, where the editor, the tooling and the next reader — probably you three months from now — can see them. In this lesson we also settle a specific debt: back in lesson 05-06 we said the dataclass annotations "are type hints, covered in module 8". That moment is now.

Contents

  1. What type hints are and, above all, what they do NOT do
  2. Basic syntax: variables, parameters and return value
  3. Modern generic types: list[Book], dict[str, Book], tuple, set
  4. | None and Optional: the contract of find_book()
  5. Union with |: when something can be several things
  6. Annotating warehouse.py: Papyrus's real signatures
  7. TypedDict: giving shape to CSV and JSON rows
  8. mypy: the external checker
  9. Real benefits and when to relax

What type hints are (and what they do NOT do)

A type hint is a label that declares what type of value is expected in a variable, a parameter or a return value:

def price_with_vat(price: float) -> float:
    return round(price * 1.04, 2)
  • price: float — the parameter should be a float.
  • -> float — the function returns a float.

And now the most important point of the whole lesson: Python does not check annotations at runtime. This code runs without complaint:

price_with_vat("free")     # TypeError, yes... but because of the *, not the annotation
price_with_vat([1, 2, 3])  # the annotations didn't stop the call

The error you'll see is the same old TypeError, raised while attempting the multiplication; the annotation neither caused it nor prevented it. Annotations are structured documentation: Python stores them (in __annotations__) but ignores them when running. It's the ecosystem that puts them to work: your editor for autocompletion, and external checkers like mypy to catch errors before the code runs. Compare this with what module 7 did:

Mechanism When it acts What it does if the type is wrong
Guard clause (raise TypeError) from 07-03 At runtime Stops the program with an actionable error
__post_init__ in Book (05-06, 07-03) At runtime, when creating the object Rejects the invalid data
Type hint Never at runtime Nothing — but mypy and the editor warn you beforehand

They aren't alternatives: they're layers. Type hints catch the error on your screen while you type; guard clauses stop it if it slips through at runtime anyway.

Basic syntax

Variables

BOOK_VAT: float = 0.04
MEMBER_DISCOUNT: float = 0.05
shop_name: str = "Papyrus"
units: int = 2
is_member: bool = True

For local variables with an obvious value (units = 2) the annotation is usually redundant: the type is inferred. Where it shines is in module-level constants, in attributes and in variables that start out "empty":

out_of_stock_titles: list[str] = []   # without the annotation, nobody knows what goes in it

Parameters and return value

def final_price(price: float, member: bool = False) -> float:
    """Applies VAT and, if applicable, the member discount."""
    price_vat = price * (1 + BOOK_VAT)
    if member:
        price_vat *= (1 - MEMBER_DISCOUNT)
    return round(price_vat, 2)

Notice how the default value coexists with the annotation: member: bool = False. A function that returns nothing is annotated with -> None:

def register_signup(code: str) -> None:
    ...

Modern generic types

Collections are annotated by stating what they contain, using square brackets. Since Python 3.9 the built-in types are used directly (you'll see List and Dict imported from typing in older code; today they're no longer needed):

from models import Book

catalog: dict[str, Book] = {}            # title → Book
pending: list[str] = ["Faust"]           # list of titles
sale: tuple[str, str, float] = ("2026-07-13", "Hamlet", 20.70)  # a sales.csv row
member_codes: set[str] = {"LUIS-001", "MARTA-002", "PAU-003"}
Annotation Reads as Example in Papyrus
list[Book] list of Book objects result of loading the CSV catalog
dict[str, Book] str keys, Book values the in-memory catalog
tuple[str, str, float] tuple of exactly 3 elements with those types a sales.csv row
tuple[str, ...] variable-length tuple, all str immutable titles
set[str] set of str valid member codes

And here we settle the promise from 05-06: in the Book dataclass, the lines title: str, price: float, stock: int = 0 are exactly this — type annotations. @dataclass reads them from __annotations__ to generate __init__ and friends. It's the one place in Python where annotations have a direct practical effect... and even so, at runtime nobody verifies that price really is a float: that's why Book needs its __post_init__.

| None: the contract of find_book()

In module 7 we designed two sibling functions with different contracts: find_book() returns the book or None (absence is a normal case), and get_book() returns the book or raises BookNotFoundError. That contract, which back then we defended in the docstring, is now written in the signature:

def find_book(catalog: dict[str, Book], title: str) -> Book | None:
    """Returns the Book, or None if it's not in the catalog."""
    return catalog.get(title)

Book | None reads as "a Book or None". It's the modern form (Python 3.10+) of Optional[Book], which you'll still see in plenty of code:

from typing import Optional

def find_book(catalog: dict[str, Book], title: str) -> Optional[Book]: ...

They're equivalent. The practical payoff: when you write find_book(catalog, "Hamlet").final_price(), a checker warns you that the result could be None and that None has no final_price() — the classic 8:05 pm AttributeError with the shop already closed, caught before running anything.

Union with |

The same operator works for any union of types, not just with None:

def load_config(key: str) -> str | float | bool:
    """Returns the value from config.json, which may be text, a number or a boolean."""
    ...

Use it sparingly: a function that returns str | float | bool | None is begging for a redesign. The clear contracts of M7 (one function, one result type, or an exception) remain the best guide.

Annotating warehouse.py: the real signatures

Here's what the headers of warehouse.py look like with their contracts in plain sight. Compare each signature with what the function already did in M6 and M7 — not a single line of logic changes:

from pathlib import Path
from models import Book

BASE: Path = Path(__file__).parent

def find_book(catalog: dict[str, Book], title: str) -> Book | None: ...

def get_book(catalog: dict[str, Book], title: str) -> Book:
    """Returns the Book or raises BookNotFoundError."""

def sell(catalog: dict[str, Book], title: str, units: int) -> float:
    """Deducts stock and returns the amount with VAT (e.g. Hamlet x2 → 20.70)."""

def restock(catalog: dict[str, Book], title: str, units: int) -> None: ...

def load_catalog(path: Path) -> dict[str, Book]: ...

def save_catalog(catalog: dict[str, Book], path: Path) -> None: ...

def close_till(sales_path: Path) -> float:
    """Sums the day's amounts; skips corrupt rows and logs them at WARNING."""

Notice how much of the course's history fits into a signature: get_book doesn't annotate Book | None because its contract is to raise, not to return None — the exception is not part of the return annotation (it's documented in the docstring). And sell returns float: the amount, not the book. Questions that in M7 you answered by rereading the body are now settled without opening the function.

TypedDict: shape for CSV and JSON rows

csv.DictReader (M6) returns dictionaries, and dict[str, str] says very little: which keys? TypedDict declares the exact shape:

from typing import TypedDict

class SaleRow(TypedDict):
    date: str
    title: str
    amount: str   # DictReader always hands over str; converting is our job (07-02)

def parse_row(row: SaleRow) -> float:
    return float(row["amount"])

With this, row["amonut"] (typo included) stops being a ticking KeyError time bomb: mypy flags it instantly. This is just a brushstroke — enough for the rows of sales.csv and the objects in catalog.json; you don't need more for now.

mypy: the external checker

If Python doesn't check the annotations, something has to. That something is mypy, installed in the project's venv like any other package (M1):

pip install mypy
mypy warehouse.py

A real example of the bug it hunts down. This code runs most of the time... until the book doesn't exist:

def price_for_julia(catalog: dict[str, Book], title: str) -> float:
    book = find_book(catalog, title)
    return book.final_price(member=False)   # what if book is None?
warehouse.py:42: error: Item "None" of "Book | None" has no attribute
"final_price"  [union-attr]
Found 1 error in 1 file (checked 1 source file)

mypy didn't run anything: it read the signatures and deduced that this AttributeError was possible. The fix is the LBYL/EAFP pattern from 07-01, and now the tool forces you to choose it consciously:

book = find_book(catalog, title)
if book is None:
    raise BookNotFoundError(title)
return book.final_price(member=False)

After the if, mypy narrows the type: it knows book is now a Book. This is called narrowing, and it's the reason annotating | None always pays off.

Benefits and when to relax

Benefits: living documentation that doesn't drift out of date like comments do (if you change the function but not the signature, mypy complains); precise autocompletion in the editor (type book. and up come final_price and in_stock); and bugs caught before running, like the None above.

When to relax: in a twenty-line script that renames the files in backups/, annotating every variable is bureaucracy. The practical rule: always annotate the public signatures of your modules (warehouse.py, models.py, errors.py — their attributes such as requested: int and available: int appreciate types too); relax in throwaway scripts and obvious local variables. Type hints are gradual by design: you can annotate one module today and another next month.

Common Mistakes and Tips

  • Believing annotations validate: def sell(units: int) does not stop sell("two"). To reject data at runtime you still need the guard clauses from 07-03. Hints warn beforehand; raise protects during.
  • Forgetting the | None in functions that may not find anything: if find_book is annotated -> Book, you're lying to the reader and to mypy, which will let the AttributeError through. The annotation must tell the whole truth.
  • Bare list instead of list[Book]: it's legal but loses almost all the value — the editor won't know how to autocomplete the elements.
  • Importing List/Optional from typing out of habit: in modern Python (3.10+), list[...] and X | None are the preferred form. Recognize the old style when reading; write the new one.
  • Tip: run mypy as part of your routine, just like you check papyrus.log. A mypy error is a bug that never reached production.
  • Tip: when an annotated signature comes out endless or full of |, don't fight the syntax — it's usually the signature telling you the function needs simplifying.

Exercises

  1. Fully annotate this Papyrus function (parameters and return value), knowing it returns the title of the most expensive book in the catalog, or None if the catalog is empty:

    def most_expensive(catalog):
        if not catalog:
            return None
        return max(catalog.values(), key=lambda b: b.price).title
    
  2. Define a TypedDict called CatalogRow for the rows of the catalog CSV (keys title, price, stock, all str because they come from DictReader) and annotate the function row_to_book(row) that turns a row into a Book (it may raise ValueError if the price isn't numeric — remember: that goes in the docstring, not in the ->).

  3. This code goes unnoticed until Omar asks for an out-of-stock title. What error would mypy flag, and how would you fix it using the contract from errors.py?

    def charge(catalog: dict[str, Book], title: str) -> float:
        book = find_book(catalog, title)
        if not book.in_stock():
            raise InsufficientStockError(title, 1, 0)
        return book.final_price()
    

Solutions

  1. def most_expensive(catalog: dict[str, Book]) -> str | None:
        if not catalog:
            return None
        return max(catalog.values(), key=lambda b: b.price).title
    

    The -> str | None is mandatory: there's a path that returns None. Annotating just -> str would be a lie.

  2. from typing import TypedDict
    
    class CatalogRow(TypedDict):
        title: str
        price: str
        stock: str
    
    def row_to_book(row: CatalogRow) -> Book:
        """Turns a CSV row into a Book. Raises ValueError if price/stock aren't numeric."""
        return Book(row["title"], float(row["price"]), int(row["stock"]))
    

    The float()/int() conversions are the same ones close_till protects with its minimal try (07-02); the possible exception is documented in the docstring.

  3. mypy flags Item "None" of "Book | None" has no attribute "in_stock": find_book can return None and None.in_stock() would blow up. Two fixes consistent with M7: check if book is None: raise BookNotFoundError(title) before using it, or simply call get_book(catalog, title), whose -> Book signature already guarantees (raising if necessary) that there is a book. The second is better: it reuses the existing contract.

Conclusion

Type hints write into the signatures the contracts that module 7 defended by hand: find_book() -> Book | None tells the whole truth, sell() -> float clears up the doubt about the amount, and the annotations in the Book dataclass — the promise left pending in 05-06 — finally have a proper name. Python doesn't check them at runtime; mypy and your editor turn them into a net that catches bugs before Julia or Omar suffer them. With the contracts now visible, the next step attacks another kind of repetition: at Papyrus we want to time close_till, audit every sale in the M7 log, and retry fragile operations — without copy-pasting the same code around every function. That's exactly what decorators solve, and they're the next lesson.

Python Programming Course

Module 1: Introduction to Python

Module 2: Control Structures

Module 3: Functions and Modules

Module 4: Data Structures

Module 5: Object-Oriented Programming

Module 6: File Handling

Module 7: Error and Exception Handling

Module 8: Advanced Topics

Module 9: Testing and Debugging

Module 10: Web Development with Python

Module 11: Data Science with Python

Module 12: Final Project

© Copyright 2026. All rights reserved