%%{init: {"flowchart": {"diagramPadding": 40}}}%%
flowchart LR
A["Code with<br/>type hints"] --> B["Type checker<br/>(mypy / pyright)"]
B --> C{"Anything<br/>mismatched?"}
C -- "yes" --> D["List of mistakes,<br/>program not run yet"]
C -- "no" --> E["Go ahead and<br/>run the program"]
Chapter 33 — Type Hints: Labelling Data, Checking Before Running
Series I4 — Advanced Python Language
All scripts for this chapter are in the kode/ch33/ folder. On the web version, you can also download them all at once (zip).
Imagine a feed store with unlabelled sacks. You must open each sack to know what is inside, and sometimes you grab the wrong one. Now imagine every sack labelled “fish pellets, 25 kg”. You know at once, and an inspector can flag a misplaced sack before the feed is used.
A type hint is that label on code: a note about the kind of data a variable or function expects. Python itself does not enforce it while the program runs, but people, editors, and checking tools can use it to find mistakes before the program runs.
Basic annotations
01_basic_annotations.py
# Chapter 33 - Type annotations: notes for humans and tools, not enforcement
def biomass_kg(count: int, weight_g: float) -> float:
return count * weight_g / 1000
age_days: int = 120
pond_name: str = "A"
print(biomass_kg(800, 540.5))
print(biomass_kg.__annotations__)
def double(count: int) -> int:
return count * 2
# Python does NOT check annotations while the program runs
print(double(21))
print(double("ab")) # not an int, yet it still runs432.4
{'count': <class 'int'>, 'weight_g': <class 'float'>, 'return': <class 'float'>}
42
abab
count: intmeans “count is expected to be a whole number”.-> floatat the end means “the function returns a decimal number”.- Variables can be labelled too:
age_days: int = 120. - Annotations are stored in
__annotations__as notes. - Python does not check them while running.
double("ab")is not anint, yet it runs and givesabab(text times two repeats the text). No error. The checking is done by a separate tool, not by Python.
A type hint is a note, not a doorkeeper. Without a checking tool, a wrong type is only discovered while the program runs, or not discovered at all like the abab above. For a doorkeeper at run time, see Pydantic in Chapter 34.
Types for collections, choices, and functions
02_collection_types.py
# Chapter 33 - Types for collections, choices, and functions
from collections.abc import Callable, Iterator
def average(numbers: list[float]) -> float:
return sum(numbers) / len(numbers)
def find_pond(data: dict[str, float], name: str) -> float | None:
"""Returns the pond weight, or None if it does not exist."""
return data.get(name)
def point(x: float, y: float) -> tuple[float, float]:
return (x, y)
def apply(function: Callable[[float], float], values: list[float]) -> list[float]:
return [function(v) for v in values]
def countdown(n: int) -> Iterator[int]:
while n > 0:
yield n
n -= 1
weights = {"A": 540.5, "B": 498.0}
print(average([540.5, 498.0, 610.0]))
print(find_pond(weights, "A"), find_pond(weights, "Z"))
print(point(1.5, 2.0))
print(apply(lambda g: g / 1000, [540.5, 498.0]))
print(list(countdown(3)))549.5
540.5 None
(1.5, 2.0)
[0.5405, 0.498]
[3, 2, 1]
| Written as | Meaning |
|---|---|
list[float] |
a list whose items are decimals |
dict[str, float] |
a dict with text keys and decimal values |
tuple[float, float] |
a tuple of two decimals |
float \| None |
a decimal or None (meaning “may be empty”) |
Callable[[float], float] |
a function taking one decimal and returning a decimal |
Iterator[int] |
something you can next() that gives whole numbers |
find_pond(weights, "Z") returns None because pond Z does not exist; that is why its type is float | None. Writing float | None uses the | sign (Python 3.10 and up). Callable and Iterator come from collections.abc. Note: countdown is a generator (Chapter 30), and the fitting type for a simple generator is Iterator[int].
Generics: types that follow the content
The function first(items) can be used on a list of decimals or a list of text. The result type must follow: decimal if given a list of decimals, text if given a list of text. That is the job of generics, with a type variable T.
03_generics.py
# Chapter 33 - Generics: types that follow the content
from collections.abc import Sequence
def first[T](items: Sequence[T]) -> T:
"""Returns the first element; its type matches the list content."""
return items[0]
print(first([540.5, 498.0])) # T = float
print(first(["A", "B"])) # T = str
class Warehouse[T]:
"""A place to store items of the same type."""
def __init__(self) -> None:
self.content: list[T] = []
def store(self, item: T) -> None:
self.content.append(item)
def take(self) -> T:
return self.content.pop()
weight_store = Warehouse[float]()
weight_store.store(540.5)
print(weight_store.take())540.5
A
540.5
The syntax def first[T](...) and class Warehouse[T] is the way of writing introduced in Python 3.12 (before that, TypeVar and Generic were used). Warehouse[float]() makes a decimals-only warehouse. A checking tool will then complain if you store text in it.
TypedDict, Literal, and Protocol
Field data often takes the form of a dict. TypedDict states which keys exist and their types. Literal limits the choices to certain values. Protocol states “anything that has this” without requiring inheritance (called duck typing: “if it walks like a duck and quacks like a duck, it is a duck”).
04_field_data_types.py
# Chapter 33 - TypedDict, Literal, and Protocol
from dataclasses import dataclass
from typing import Literal, Protocol, TypedDict
Sector = Literal["forest", "orchard", "ricefield", "pond", "barn"]
class Record(TypedDict):
sector: Sector
unit: str
value: float
@dataclass
class Plot:
name: str
area_ha: float
class HasName(Protocol):
"""A 'duck' type: anything that has a name attribute."""
name: str
def greet(thing: HasName) -> str:
return "Hello, " + thing.name
r: Record = {"sector": "pond", "unit": "A", "value": 540.5}
print(r["sector"], r["value"])
print(greet(Plot("Block A", 1.5)))pond 540.5
Hello, Block A
Record is a dict with three keys, and sector may only be one of five values. greet() accepts anything with a name attribute: here Plot, a dataclass (Chapter 16), inheriting from nothing. While running, everything is an ordinary dict and an ordinary object; the labels are only for the checker.
Running the real mypy
mypy is a type-checking tool. The script below writes a small file that is deliberately wrong, runs mypy on it, and shows the real output.
05_run_mypy.py
# Chapter 33 - Running the real mypy on a small file
import subprocess
import sys
from pathlib import Path
folder = Path(__file__).parent / "_tmp"
folder.mkdir(exist_ok=True)
example = folder / "wrong_example.py"
example.write_text('''def biomass_kg(count: int, weight_g: float) -> float:
return count * weight_g / 1000
total: int = biomass_kg(800, 540.5)
print(biomass_kg("800", 540.5))
print(biomass_kg(800))
name: str = None
''', encoding="utf-8")
result = subprocess.run(
[sys.executable, "-m", "mypy", "--no-color-output", "--no-error-summary",
"--cache-dir", str(folder / ".mypy_cache"), "wrong_example.py"],
cwd=folder, capture_output=True, text=True,
)
print(result.stdout.strip())
print("mypy exit code:", result.returncode)wrong_example.py:5: error: Incompatible types in assignment (expression has type "float", variable has type "int") [assignment]
wrong_example.py:6: error: Argument 1 to "biomass_kg" has incompatible type "str"; expected "int" [arg-type]
wrong_example.py:7: error: Missing positional argument "weight_g" in call to "biomass_kg" [call-arg]
wrong_example.py:8: error: Incompatible types in assignment (expression has type "None", variable has type "str") [assignment]
mypy exit code: 1
Mypy finds four mistakes without running the file:
- Line 5:
biomass_kgreturns afloat, but it is stored in a variable promised to beint. - Line 6: the first argument is the text
"800", while anintis expected. - Line 7: the argument
weight_gis missing. - Line 8:
Noneis given to a variable of typestr.
The code in square brackets at the end ([assignment], [arg-type], [call-arg]) is the name of the error category. The mypy exit code 1 means “there are problems”, which is useful when mypy is run automatically (for example in a pre-commit check, Chapters 19 and 20).
Start small. Add type hints to the most used functions, run mypy, fix, then expand. mypy’s --strict option is stricter (it demands types on almost every function); it suits a project that is already tidy.
Pyright, another checking tool
Besides mypy, there is pyright, a type checker made by Microsoft. On its project page, pyright is described as a static type checker for Python offered as a command-line tool and a Visual Studio Code extension. Pyright is not run in this book; how to install it and how its behaviour differs from mypy have not been verified on the author’s computer, so please consult its own documentation if you want to try it. The concepts you learn here (annotations, generics, TypedDict) apply equally to both.
Python 3.14: annotations are evaluated later
In the past, an annotation such as x: SomeClass was evaluated immediately when def ran. If SomeClass was not yet defined, an error occurred, so people wrote "SomeClass" (in quotes) or from __future__ import annotations. Since Python 3.14, annotations are stored and evaluated only when needed (source: the “What’s New in Python 3.14” documentation, section on deferred annotations). As a result, a reference to a name that does not exist yet no longer needs quotes. The new module annotationlib reads annotations in three formats.
06_deferred_annotations.py
# Chapter 33 - Python 3.14: annotations are evaluated later
from annotationlib import Format, get_annotations
def compute(x: NotCreatedYet) -> None: # this name does NOT exist yet
pass
print("function was created without any error")
print(get_annotations(compute, format=Format.STRING))
reference = get_annotations(compute, format=Format.FORWARDREF)["x"]
print(type(reference).__name__, reference.__forward_arg__)
try:
get_annotations(compute, format=Format.VALUE)
except NameError as e:
print("NameError:", e)
class NotCreatedYet:
pass
print(get_annotations(compute, format=Format.VALUE)) # now the name existsfunction was created without any error
{'x': 'NotCreatedYet', 'return': 'None'}
ForwardRef NotCreatedYet
NameError: name 'NotCreatedYet' is not defined
{'x': <class '__main__.NotCreatedYet'>, 'return': None}
compute(x: NotCreatedYet)is created successfully even thoughNotCreatedYetdoes not exist. On older Pythons this is an immediateNameError.- The
STRINGformat shows the annotation as text,FORWARDREFas a reference (ForwardRef) for a name that does not exist yet, andVALUEreally evaluates the value. - The
VALUEformat before the class exists gives aNameError. AfterNotCreatedYetis defined,VALUEsucceeds and returns the class.
This script runs only on Python 3.14 or later, because annotationlib is new there and the behaviour “the function is created even though the name is missing” is also specific to 3.14. Readers still on an older Python should keep writing forward references in quotes. The code from __future__ import annotations is still recognised and keeps the old behaviour.
Running Project I4: typing the project code
We add type hints to the record-reading functions from Chapter 30, then check with mypy in strict mode. The script writes a small module field.py into _tmp/, runs mypy, inserts one deliberate mistake, runs mypy again, then runs the module.
07_project_typed_code.py
# Chapter 33 - Running project I4: typing the project code, checked by mypy
import subprocess
import sys
from pathlib import Path
folder = Path(__file__).parent / "_tmp"
folder.mkdir(exist_ok=True)
module = folder / "field.py"
module.write_text('''from collections.abc import Iterable, Iterator
from typing import Literal, TypedDict, cast
Sector = Literal["forest", "orchard", "ricefield", "pond", "barn"]
class Record(TypedDict):
sector: Sector
unit: str
value: float
def read(lines: Iterable[str]) -> Iterator[Record]:
for line in lines:
sector, unit, value = line.split(",")
yield {"sector": cast(Sector, sector), "unit": unit, "value": float(value)}
def average(records: Iterable[Record]) -> float | None:
values = [r["value"] for r in records]
return sum(values) / len(values) if values else None
print(average(read(["pond,A,540.5", "pond,B,498.0"])))
print(average(read([])))
''', encoding="utf-8")
def mypy(*options: str) -> None:
result = subprocess.run(
[sys.executable, "-m", "mypy", "--no-color-output", "--cache-dir", str(folder / ".mypy_cache"),
*options, "field.py"],
cwd=folder, capture_output=True, text=True,
)
print(result.stdout.strip())
print("== mypy ==")
mypy("--strict")
print("== one mistake added on purpose ==")
module.write_text(module.read_text(encoding="utf-8") + "print(average([1.5, 2.5]))" + chr(10), encoding="utf-8")
mypy("--strict")
print("== running the module ==")
run = subprocess.run([sys.executable, "field.py"], cwd=folder, capture_output=True, text=True)
print(run.stdout.strip())
print("error while running:", run.stderr.strip().splitlines()[-1])== mypy ==
Success: no issues found in 1 source file
== one mistake added on purpose ==
field.py:26: error: List item 0 has incompatible type "float"; expected "Record" [list-item]
field.py:26: error: List item 1 has incompatible type "float"; expected "Record" [list-item]
Found 2 errors in 1 file (checked 1 source file)
== running the module ==
519.25
None
error while running: TypeError: 'float' object is not subscriptable
Follow along:
- Mypy
--strictdeclares the module clean:Success: no issues found in 1 source file.cast(Sector, sector)tells mypy that we are sure this text is one of theSectorvalues (mypy cannot know that from free text). That is our promise, not a check; the real check comes in Chapter 34. - After the line
print(average([1.5, 2.5]))is inserted (it should be givenRecords, not numbers), mypy reports two errors, one for each list element. The program has not been run at that point. - If the module is run anyway, 519.25 and
Nonefrom the first two lines are printed, then the program crashes at the inserted line withTypeError: 'float' object is not subscriptable. Mypy caught the same mistake without waiting for the program to crash.
Exercises
- Add type hints to a function
average_weight(weights)that takes alist[float]and returns afloat. - Write a function
find_tree(items: list[str], name: str) -> int | Nonethat returns the position ofnamein the list, orNoneif absent. - Change
05_run_mypy.py: fix the four mistakes in the example file, run again, and see theSuccessmessage. - Challenge: make a
TypedDictcalledLivestock(keysbarn: str,kind: Literal["cattle", "goat", "chicken"],weight_kg: float), then a functiontotal_weight(data: list[Livestock]) -> float. Check with mypy, including one record whosekindhas a typo.
Chapter Summary
- A type hint is a type label on variables and functions; Python does not check it while running.
- Tools such as mypy (and pyright) check types before the program runs and report mismatches.
- Common types:
list[float],dict[str, float],float | None,Callable,Iterator; generics ([T]) make types follow the content. TypedDict,Literal, andProtocolstate the shape of a dict, limited choices, and “anything that has this”.- Python 3.14 defers the evaluation of annotations;
annotationlibreads them in theVALUE,FORWARDREF, andSTRINGformats. - Project I4: the record-reading code is typed and passes
mypy --strict; the inserted mistake is visible before running.
Next, in Chapter 34: data validation and configuration with Pydantic, a doorkeeper that works while the program runs.