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.
- Annotate variables and functions with built-in generics, unions,
Callable, and abstract collection types - Write generic functions, classes, and aliases with the modern PEP 695 syntax (
def first[T],class Box[T],type) - Model structure with
Protocol,TypedDict,Literal,Final,NewType,@overload,Self, andParamSpec - Explain Python 3.14's deferred evaluation of annotations and read them with
annotationlib - Set up mypy in strict mode with Pixi and read its error messages fluently
- Recognize the Python 3.15 typing previews: closed
TypedDicts andTypeForm
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:
- Static checking. A type checker (mypy, pyright, and others) reads your code without running it and reports places where the types cannot fit together.
- Tooling. Editors use hints for autocomplete, go-to-definition, and safe renames.
- 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.
| Annotation | Meaning |
|---|---|
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:
Iterable[T]: anything you can loop over once (lists, tuples, sets, generators, files, dict keys).Sequence[T]: indexable and sized, read-only (lists, tuples, strings, ranges).Mapping[K, V]: read-only dict-like;MutableMappingif you modify it.Collection[T]: sized, iterable, supportsin.
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.
- Bounds:
def biggest[T: Comparable](xs: list[T]) -> T:Tmust be a subtype of the bound. - Constraints:
def concat[S: (str, bytes)](a: S, b: S) -> S:Sis exactly one of the listed types, the same one for both arguments. - The
typestatement creates a lazily evaluated alias, so it can refer to itself (asJSONdoes) or to names defined later.
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:
- Forward references. A class could not mention itself in its own method annotations
(
def merge(self, other: Node)raisedNameError, becauseNodedoes not exist until the class body finishes). The workaround was quoting:other: "Node". - Cost. Every annotation expression was evaluated at import time even if nothing ever looked at it.
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:
| Format | Result | If a name is undefined |
|---|---|---|
Format.VALUE (default) | real objects, like <class 'int'> | raises NameError |
Format.FORWARDREF | real objects where possible | substitutes a ForwardRef('Name') |
Format.STRING | the 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).
no-untyped-def: add annotations. Foritems, a Protocol withpriceandqtyattributes is ideal.return-value: the signature lies. The function can returnNone, so the honest type isint | None.operator: a real bug that would raiseTypeErrorat run time. Use an f-string.
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
reveal_type(expr): mypy prints the type it inferred. (It exists at run time too, intyping, and prints the runtime type.) Remove it when done.# type: ignore[code]: silence one specific error on one line. Always include the code, and leave a comment explaining why; strict mode reports ignores that are no longer needed.- Third-party libraries without hints need stubs (
pixi add types-requestsstyle packages) or an overrideignore_missing_imports = truefor that module only.
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
Quiz
Recap
- Hints are optional metadata for checkers, editors, and readers; the interpreter does not enforce them.
- Use built-in generics (
list[int],dict[str, X]) andX | None; narrow before use. - Accept abstract (
Iterable,Sequence,Mapping,Callable), return concrete. - Generics:
def f[T],class C[T],type Alias = ...; bounds and constraints restrictT. - Protocols give static duck typing. TypedDict, Literal, Final, overload, Self, ParamSpec, NewType, cast, and TYPE_CHECKING cover the common patterns.
- Python 3.14 evaluates annotations lazily; read them with
annotationlib.get_annotationsand the rightFormat. - Run
mypyin strict mode through Pixi; read errors as file:line, got/expected, [code].
Going further
typingandannotationlibdocumentation- The Python typing specification and guides
- mypy documentation, especially "Common issues" and the error code list
- PEP 695, PEP 649, PEP 749, PEP 728, PEP 747
- Next: M17 Testing with pytest. Types and tests catch different bugs; professionals use both.