Part 2 · Intermediate Python · Module 16

Type Hints & Static Typing

Type hints let you write down what kind of values your code expects and returns, and let a static checker such as mypy find bugs before the code ever runs. In a large code base they are documentation that cannot go stale, autocomplete that actually works, and a safety net for every refactor.

⏱ ~3 hoursPrerequisites: M12 Inheritance & Protocols, M13 Dataclasses

Why types?

Python is dynamically typed: a name can refer to any kind of object, and type errors appear only when the offending line runs. That is wonderful for quick scripts and painful for large programs, where the line that passes a str to a function expecting an int might run once a month, in production.

Type hints (also called annotations) are optional labels such as def area(width: float, height: float) -> float. They do three jobs:

  1. Static checking. A type checker (mypy, pyright, and others) reads your code without running it and reports places where the types cannot fit together.
  2. Tooling. Editors use hints for autocomplete, go-to-definition, and safe renames.
  3. Documentation. A signature like def load(path: Path) -> list[Order] tells the reader more than a paragraph of prose, and the checker keeps it honest.

The crucial fact: the interpreter does not enforce hints. They are metadata attached to your functions and classes. Python remains dynamically typed; hints add an optional static layer on top. This approach is called gradual typing: you can add hints one function at a time, and unannotated code is treated leniently.

Remember: hints are for tools and readers. Runtime behavior does not change.

Annotating variables and functions

The syntax is a colon after a name and -> before a function's return type:

name: str = "Ada"                 # variable annotation
retries: int                      # annotation without a value (declares the type)

def greet(name: str, excited: bool = False) -> str:
    return f"Hi {name}{'!' if excited else '.'}"

def log(message: str) -> None:   # returns nothing: annotate None explicitly
    print(message)

Built-in generics

Containers take type parameters in square brackets describing what they hold. Since Python 3.9 you use the built-in types directly; the old typing.List and typing.Dict are deprecated aliases you will only see in older code.

AnnotationMeaning
list[int]a list whose items are all ints
dict[str, float]keys are str, values are float
set[str], frozenset[str]a set of strings
tuple[int, str]exactly two items: an int, then a str
tuple[int, ...]any number of ints (a tuple used as an immutable list)
dict[str, list[int]]nesting works as you would expect

Unions and optional values

int | str means "an int or a str". The most common union is X | None, for a value that may be missing. Older code writes Optional[X] or Union[X, None]; prefer the | spelling.

def find_index(items: list[str], target: str) -> int | None:
    for i, item in enumerate(items):
        if item == target:
            return i
    return None

idx = find_index(["a", "b"], "b")
idx + 1            # mypy: Unsupported operand types for + ("None" and "int")
if idx is not None:
    idx + 1        # fine: inside this branch mypy has *narrowed* idx to int

That last idea is called narrowing: after if x is not None, isinstance(x, str), or an early return, the checker knows a more precise type for the rest of the block. Most "Optional" errors are fixed by narrowing, not by casting.

Annotations are stored on the object, and typing.get_type_hints returns them as real objects:

Remember: annotate every parameter and the return of public functions; use list[int]-style generics and X | None; narrow before use.

Callables and abstract collections

Callable

Functions are values (M15), so they need types too. Callable[[int, str], bool] is "a callable that takes an int and a str and returns a bool". Callable[..., T] accepts any arguments.

from collections.abc import Callable

def apply(fn: Callable[[float], float], values: list[float]) -> list[float]:
    return [fn(v) for v in values]

Accept abstract, return concrete

collections.abc defines abstract types for capabilities rather than specific classes:

The professional rule of thumb is accept the most general type you can use, return the most specific type you produce. If your function only loops over its input, annotate it Iterable[float]: callers can then pass a list, a tuple, a set, or a generator. If you annotated list[float], mypy would reject a tuple even though your code would work. Conversely, return list[float] rather than Iterable[float] so callers can index and append without a cast.

Remember: parameters: Iterable, Sequence, Mapping, Callable. Return values: concrete list, dict, etc.

Generics: PEP 695 syntax and the type statement

What is the return type of a function that returns the first element of a list? It depends on the list: for a list[int] it is int; for a list[str], str. A type variable expresses "the same type, whatever it is". Functions and classes that use type variables are called generic.

def first[T](xs: list[T]) -> T:
    return xs[0]

class Box[T]:
    def __init__(self, item: T) -> None:
        self.item = item

    def get(self) -> T:
        return self.item

type Pair[T] = tuple[T, T]          # a generic type alias
type JSON = dict[str, JSON] | list[JSON] | str | int | float | bool | None
from typing import Generic, TypeVar, TypeAlias

T = TypeVar("T")

def first(xs: list[T]) -> T:
    return xs[0]

class Box(Generic[T]):
    def __init__(self, item: T) -> None:
        self.item = item

Pair: TypeAlias = tuple[T, T]

The square brackets after the name (PEP 695, Python 3.12) declare type parameters right where they are used, so there is no module-level TypeVar to define and no Generic base class. You will still read the legacy form in older libraries; the two mean the same thing.

Remember: def f[T](...), class C[T], and type Alias[T] = ... are the modern spellings; a bound restricts T to subtypes.

Protocols: static duck typing

Python's culture is duck typing: "if it has a .read() method, treat it like a file". Inheritance-based types fight this: annotating a parameter as io.TextIOBase rejects your own perfectly good reader class. A Protocol (introduced in M12) describes the methods and attributes an object must have; any class that has them matches, with no inheritance. This is structural typing, as opposed to nominal typing (matching by declared class).

from typing import Protocol

class SupportsClose(Protocol):
    def close(self) -> None: ...

def shutdown(resources: list[SupportsClose]) -> None:
    for r in resources:
        r.close()

shutdown([open("a.txt"), socket_obj, MyPool()])   # all fine if each has close()

By default a Protocol exists only for the type checker. Add @runtime_checkable to allow isinstance checks too, with a caveat: at run time Python only checks that the methods exist, not their signatures.

Protocols are how you write the interfaces of a system: the Library in Project 2 depends on a Clock protocol and a Notifier protocol, so tests can pass fakes.

Remember: a Protocol says "anything with these members". Prefer it over a concrete base class for parameters.

The typing toolbox

The typing module has a construct for most patterns you will meet. Here are the ones professionals use weekly, each with the problem it solves.

JSON-shaped dicts with known keys. The value is a plain dict at run time; the checker tracks each key's type and flags typos.

from typing import NotRequired, TypedDict

class Movie(TypedDict):
    title: str
    year: int
    rating: NotRequired[float]     # may be absent

m: Movie = {"title": "Alien", "year": 1979}
m["yaer"]        # mypy: TypedDict "Movie" has no key "yaer"

A parameter that accepts only specific values: a lightweight enum.

from typing import Literal

type Mode = Literal["r", "w", "a"]

def open_log(mode: Mode) -> None: ...
open_log("x")    # mypy: incompatible type "Literal['x']"; expected "Literal['r', 'w', 'a']"

A name that must never be reassigned (a constant), or a method that subclasses must not override (@final).

from typing import Final

MAX_RETRIES: Final = 3
MAX_RETRIES = 4  # mypy: Cannot assign to final name "MAX_RETRIES"

When the return type depends on the argument type. Write the typed stubs with @overload, then one real implementation.

from typing import overload

@overload
def parse(raw: str) -> int: ...
@overload
def parse(raw: bytes) -> str: ...
def parse(raw: str | bytes) -> int | str:
    return int(raw) if isinstance(raw, str) else raw.decode()

reveal_type(parse("1"))    # mypy note: Revealed type is "int"
reveal_type(parse(b"x"))   # mypy note: Revealed type is "str"

Methods that return their own instance, such as fluent builders and alternative constructors. With Self, a subclass's method returns the subclass type automatically.

from typing import Self

class Query:
    def where(self, clause: str) -> Self:
        self.clauses.append(clause)
        return self

    @classmethod
    def from_file(cls, path: str) -> Self: ...

Typing decorators (M15) so the decorated function keeps its exact signature. **P captures the parameters; R the return type.

import functools
from collections.abc import Callable

def logged[**P, R](func: Callable[P, R]) -> Callable[P, R]:
    @functools.wraps(func)
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
        return func(*args, **kwargs)
    return wrapper

@logged
def add(a: int, b: int) -> int: return a + b
add(1, "2")   # mypy: Argument 2 to "add" has incompatible type "str"; expected "int"

NewType makes a distinct type for the checker with zero run-time cost, so you cannot pass a UserId where an OrderId is expected even though both are ints. cast tells the checker "trust me"; it does nothing at run time, so use it sparingly.

from typing import NewType, cast

UserId = NewType("UserId", int)
OrderId = NewType("OrderId", int)
def cancel(order: OrderId) -> None: ...
cancel(UserId(7))      # mypy error: expected "OrderId"

data = cast(dict[str, int], json.loads(text))   # unchecked promise

typing.TYPE_CHECKING is False at run time and treated as True by checkers. Put imports needed only for annotations under it to avoid import cycles or slow imports.

from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from shop.models import Order    # never imported at run time

def total(order: Order) -> float:    # fine in 3.14: annotations are evaluated lazily
    ...

Two more you need to know: Any switches checking off for a value (it is compatible with everything, in both directions), while object accepts everything but lets you do almost nothing with the value until you narrow it. Prefer object when you mean "anything"; Any is an escape hatch.

Remember: TypedDict for dict records, Literal for fixed choices, Final for constants, @overload for type-dependent returns, Self for fluent methods, ParamSpec for decorators, NewType for distinct ids.

Python 3.14: deferred evaluation of annotations

Until Python 3.13, annotations were evaluated eagerly, at the moment the def or class statement ran. That caused two long-standing headaches:

Step through this program: the def line succeeds even though Ledger does not exist yet, because nothing evaluates the annotation.

Reading annotations with annotationlib

Since evaluation is now deferred, how you ask for annotations matters. The new standard-library module annotationlib offers get_annotations(obj, format=...) with three main formats:

FormatResultIf a name is undefined
Format.VALUE (default)real objects, like <class 'int'>raises NameError
Format.FORWARDREFreal objects where possiblesubstitutes a ForwardRef('Name')
Format.STRINGthe source text, like 'list[Node]'never fails

Remember: in 3.14, annotations are computed on demand. Forward references need no quotes. Use annotationlib.get_annotations(obj, format=...) to read them.

mypy in strict mode with Pixi

mypy is the original Python type checker. It is a development tool, not a runtime dependency, so add it to your Pixi workspace (conda-forge has it) and configure it in pyproject.toml:

pixi add mypy                 # or: pixi add --feature dev mypy
pixi run mypy src/            # check your package
pixi run mypy --strict src/   # strict mode from the command line
[tool.mypy]
python_version = "3.12"   # the oldest Python you support
strict = true             # turn on every optional check
warn_unreachable = true
files = ["src"]
[tasks]
check = "mypy src/"

strict = true enables, among others: every function must be annotated (disallow_untyped_defs), no implicit Any from missing generics (list must be list[int]), warnings for returning Any, and errors for unused type: ignore comments. Strict mode is the standard for new professional code; adding it to an old code base is usually done one module at a time with per-module overrides.

Reading mypy errors

Take this untyped, slightly buggy module:

def total_price(items, tax_rate=0.08):
    return sum(item.price * item.qty for item in items) * (1 + tax_rate)


def find(names: list[str], target: str) -> int:
    for i, name in enumerate(names):
        if name == target:
            return i
    return None


def label(count: int) -> str:
    return "items: " + count
$ pixi run mypy --strict src/
src/shop/inventory.py:1: error: Function is missing a type annotation  [no-untyped-def]
src/shop/inventory.py:9: error: Incompatible return value type (got "None", expected "int")  [return-value]
src/shop/inventory.py:13: error: Returning Any from function declared to return "str"  [no-any-return]
src/shop/inventory.py:13: error: Unsupported operand types for + ("str" and "int")  [operator]
Found 4 errors in 1 file (checked 1 source file)

Every line has the same anatomy: file:line, severity, a message that usually says got X, expected Y, and an error code in brackets. Read them top to bottom and fix the first error in each function first, since later ones are often consequences (here, the no-any-return on line 13 disappears once the operator error is fixed).

The fixed module (passes mypy --strict)
from collections.abc import Iterable
from typing import Protocol


class Priced(Protocol):
    price: float
    qty: int


def total_price(items: Iterable[Priced], tax_rate: float = 0.08) -> float:
    return sum(item.price * item.qty for item in items) * (1 + tax_rate)


def find(names: list[str], target: str) -> int | None:
    for i, name in enumerate(names):
        if name == target:
            return i
    return None


def label(count: int) -> str:
    return f"items: {count}"

Tools of the trade

Preview: typing in Python 3.15

Run 3.15 code locally with pixi exec --spec "python=3.15" python file.py. Checkers add support for new typing features on their own schedule, so check your mypy version's release notes before relying on them.

Practice

The browser cannot run mypy, so these exercises check your annotations by inspecting them at run time with the same functions libraries use (get_type_hints, annotationlib), plus behavior tests.

Add type hints to word_lengths: it takes a list of str and an optional minimum length (an int, default 0) and returns a dict mapping each word (str) to its length (int). Use built-in generics. Also annotate longest, which takes the same kind of list and returns a str or None when the list is empty. Implement longest.

Hint

def longest(words: list[str]) -> str | None: then return max(words, key=len) if words else None.

Show solution
def word_lengths(words: list[str], min_length: int = 0) -> dict[str, int]:
    return {w: len(w) for w in words if len(w) >= min_length}

def longest(words: list[str]) -> str | None:
    return max(words, key=len) if words else None

Using PEP 695 syntax, write a generic class Stack[T] with push(item: T) -> None, pop() -> T (raise IndexError with the message "pop from empty stack" when empty), peek() -> T | None, and __len__. Then write a generic function last[T](items: Sequence[T]) -> T, and a type alias type Grid = list[list[int]].

Hint

Start with class Stack[T]: and store items in self._items: list[T] = []. Inside the class body, T is in scope for every method's annotations.

Show solution
from collections.abc import Sequence

class Stack[T]:
    def __init__(self) -> None:
        self._items: list[T] = []

    def push(self, item: T) -> None:
        self._items.append(item)

    def pop(self) -> T:
        if not self._items:
            raise IndexError("pop from empty stack")
        return self._items.pop()

    def peek(self) -> T | None:
        return self._items[-1] if self._items else None

    def __len__(self) -> int:
        return len(self._items)

def last[T](items: Sequence[T]) -> T:
    return items[-1]

type Grid = list[list[int]]

Define a runtime-checkable Protocol HasArea with a method area(self) -> float. Write two classes, Square(side) and Circle(radius), that do not inherit from it but match it structurally (use math.pi). Finally, write total_area(shapes: Iterable[HasArea]) -> float.

Hint

Decorate the Protocol with @runtime_checkable; its method body is just .... Then return sum(s.area() for s in shapes) (convert to float so an empty input gives 0.0).

Show solution
import math
from collections.abc import Iterable
from typing import Protocol, runtime_checkable

@runtime_checkable
class HasArea(Protocol):
    def area(self) -> float: ...

class Square:
    def __init__(self, side: float) -> None:
        self.side = side

    def area(self) -> float:
        return self.side ** 2

class Circle:
    def __init__(self, radius: float) -> None:
        self.radius = radius

    def area(self) -> float:
        return math.pi * self.radius ** 2

def total_area(shapes: Iterable[HasArea]) -> float:
    return float(sum(s.area() for s in shapes))

Define class Order(TypedDict) with required keys id: int and status: Status, plus an optional key note: str (use NotRequired). Define Status with the type statement as Literal["new", "paid", "shipped"]. Then write advance(order: Order) -> Order that returns a new dict with the next status (new → paid → shipped) and raises ValueError for an order that is already shipped. Do not modify the input.

Hint

Put type Status = Literal["new", "paid", "shipped"] after the class if you like: the alias and the class annotations are both evaluated lazily. Use a dict {"new": "paid", "paid": "shipped"} for the transitions and {**order, "status": nxt} to copy.

Show solution
from typing import Literal, NotRequired, TypedDict

type Status = Literal["new", "paid", "shipped"]

class Order(TypedDict):
    id: int
    status: Status
    note: NotRequired[str]

_NEXT = {"new": "paid", "paid": "shipped"}

def advance(order: Order) -> Order:
    nxt = _NEXT.get(order["status"])
    if nxt is None:
        raise ValueError(f"order {order['id']} is already shipped")
    return {**order, "status": nxt}

Write describe(func) that returns a list of strings, one per parameter, formatted "name: annotation", followed by "return: annotation" if there is one, using each annotation's source text. It must work even when an annotation names a class that does not exist. Use annotationlib.get_annotations with the right Format. Unannotated parameters are skipped.

Hint

get_annotations(func, format=Format.STRING) returns a dict in parameter order with "return" last. Format each item as f"{name}: {text}".

Show solution
from annotationlib import Format, get_annotations

def describe(func):
    annotations = get_annotations(func, format=Format.STRING)
    return [f"{name}: {text}" for name, text in annotations.items()]

Mini-project: type an untyped module to strict

Mini-projectFrom zero hints to mypy --strict

Take an untyped module (your Project 1 ledger, an old script, or the one below) and add types until pixi run mypy --strict src/ reports Success, without a single blanket # type: ignore.

import json

def load(path):
    with open(path, encoding="utf-8") as f:
        return json.load(f)

def average(scores):
    return sum(scores) / len(scores) if scores else None

def by_student(records):
    out = {}
    for r in records:
        out.setdefault(r["student"], []).append(r["score"])
    return out

def report(records, fmt=lambda x: f"{x:.1f}"):
    lines = []
    for student, scores in sorted(by_student(records).items()):
        avg = average(scores)
        lines.append(f"{student}: {fmt(avg)}")
    return "\n".join(lines)
pixi init grades && cd grades
pixi add mypy pytest
pixi run mypy --strict src/      # read every error, fix one function at a time
  • Run mypy strict on the untouched module and save the error list
  • Model a record with a TypedDict and use it everywhere instead of dict
  • Parameters use abstract types (Iterable, Mapping, Callable); returns are concrete
  • Handle the float | None from average by narrowing, and find the real bug mypy reveals in report
  • Validate json.load's Any at the boundary instead of trusting it with cast
  • Add [tool.mypy] strict = true to pyproject.toml and a check Pixi task; it passes

Stretch: add a generic helper group_by[K, V](items: Iterable[V], key: Callable[[V], K]) -> dict[K, list[V]] and use it in by_student; type a decorator from M15 with ParamSpec; run pyright too (pixi add pyright) and compare the messages.

Quiz

Recap

Going further