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
- What type hints are and, above all, what they do NOT do
- Basic syntax: variables, parameters and return value
- Modern generic types:
list[Book],dict[str, Book],tuple,set | NoneandOptional: the contract offind_book()Unionwith|: when something can be several things- Annotating
warehouse.py: Papyrus's real signatures TypedDict: giving shape to CSV and JSON rowsmypy: the external checker- 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:
price: float— the parameter should be afloat.-> float— the function returns afloat.
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 callThe 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 = TrueFor 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":
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:
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):
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 stopsell("two"). To reject data at runtime you still need the guard clauses from 07-03. Hints warn beforehand;raiseprotects during. - Forgetting the
| Nonein functions that may not find anything: iffind_bookis annotated-> Book, you're lying to the reader and tomypy, which will let theAttributeErrorthrough. The annotation must tell the whole truth. - Bare
listinstead oflist[Book]: it's legal but loses almost all the value — the editor won't know how to autocomplete the elements. - Importing
List/Optionalfromtypingout of habit: in modern Python (3.10+),list[...]andX | Noneare the preferred form. Recognize the old style when reading; write the new one. - Tip: run
mypyas part of your routine, just like you checkpapyrus.log. Amypyerror 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
-
Fully annotate this Papyrus function (parameters and return value), knowing it returns the title of the most expensive book in the catalog, or
Noneif the catalog is empty:def most_expensive(catalog): if not catalog: return None return max(catalog.values(), key=lambda b: b.price).title -
Define a
TypedDictcalledCatalogRowfor the rows of the catalog CSV (keystitle,price,stock, allstrbecause they come fromDictReader) and annotate the functionrow_to_book(row)that turns a row into aBook(it may raiseValueErrorif the price isn't numeric — remember: that goes in the docstring, not in the->). -
This code goes unnoticed until Omar asks for an out-of-stock title. What error would
mypyflag, and how would you fix it using the contract fromerrors.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
-
def most_expensive(catalog: dict[str, Book]) -> str | None: if not catalog: return None return max(catalog.values(), key=lambda b: b.price).titleThe
-> str | Noneis mandatory: there's a path that returnsNone. Annotating just-> strwould be a lie. -
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 onesclose_tillprotects with its minimaltry(07-02); the possible exception is documented in the docstring. -
mypyflagsItem "None" of "Book | None" has no attribute "in_stock":find_bookcan returnNoneandNone.in_stock()would blow up. Two fixes consistent with M7: checkif book is None: raise BookNotFoundError(title)before using it, or simply callget_book(catalog, title), whose-> Booksignature 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
- Introduction to Python
- Setting Up the Development Environment
- Python Syntax and Basic Data Types
- Variables and Constants
- Basic Input and Output
- Virtual Environments and Package Management
Module 2: Control Structures
Module 3: Functions and Modules
- Defining Functions
- Function Arguments
- Lambda Functions
- Modules and Packages
- Standard Library Overview
Module 4: Data Structures
Module 5: Object-Oriented Programming
Module 6: File Handling
Module 7: Error and Exception Handling
- Introduction to Exceptions
- Handling Exceptions
- Raising Exceptions
- Custom Exceptions
- Best Practices and Error Logging
Module 8: Advanced Topics
- Type Hints
- Decorators
- Generators
- Context Managers
- Concurrency: Threads and Processes
- Asyncio for Asynchronous Programming
Module 9: Testing and Debugging
- Introduction to Testing
- Unit Testing with unittest
- Testing with pytest
- Test-Driven Development
- Debugging Techniques
- Using pdb for Debugging
Module 10: Web Development with Python
- Introduction to Web Development
- Flask Framework Fundamentals
- Building REST APIs with Flask
- Introduction to Django
- Building Web Applications with Django
Module 11: Data Science with Python
- Introduction to Data Science
- NumPy for Numerical Computing
- Pandas for Data Manipulation
- Matplotlib for Data Visualization
- Introduction to Machine Learning with scikit-learn
