def show_count(count, word):
if count == 1:
return f"1 {word}"
return f"{count or 'no'} {word}s"
show_count(3, "cat")'3 cats'
Type hints are optional, checked by tools (mypy, pyright), and have zero runtime cost. They are documentation that can be verified. Python’s type system is “gradual” — you add hints incrementally.
In this chapter you will learn to:
Optional, Union, and the modern X | Y syntax for choices.list[int], dict[str, float], tuple[int, ...].Iterable, Sequence, Mapping) for parameters.TypeVar to write functions that preserve element types.typing.Protocol.A function without hints is still legal:
def show_count(count, word):
if count == 1:
return f"1 {word}"
return f"{count or 'no'} {word}s"
show_count(3, "cat")'3 cats'
Without hints, you can call show_count("three", "cat") and the bug only surfaces when the format string runs. Hints let a static checker catch this before the program runs:
def show_count(count: int, word: str) -> str:
if count == 1:
return f"1 {word}"
return f"{count or 'no'} {word}s"
show_count(1, "fish"), show_count(0, "bird"), show_count(3, "cat")('1 fish', 'no birds', '3 cats')
Walking through the syntax:
count: int annotates the parameter — “I expect count to be an int.” The colon plus type expression is Python’s annotation syntax; it works on parameters, return values, and bare assignments alike.word: str is the same for the second parameter.-> str annotates the return type — “this function returns a str.” The arrow goes between the parameter list and the body’s colon.show_count.__annotations__) but doesn’t enforce them. A static checker like mypy or pyright reads them and flags mismatches; the interpreter shrugs.The general pattern: hints are documentation that a tool can verify. Add them when they earn their keep — usually at API boundaries where wrong types would cause real bugs.
For optional parameters, write X | None (Python 3.10+) or Optional[X]:
def show_count(count: int, singular: str, plural: str | None = None) -> str:
if count == 1:
return f"1 {singular}"
return f"{count or 'no'} {plural or singular + 's'}"
show_count(1, "fox"), show_count(2, "fox"), show_count(2, "child", "children")('1 fox', '2 foxs', '2 children')
The duck-typing view is that a type is what a value can do:
def double(x):
return x * 2
double(2), double("ab"), double([1, 2])(4, 'abab', [1, 2, 1, 2])
int, str, and list all support * — and double works on all three. The type-system view writes the same idea using TypeVar:
from typing import TypeVar
T = TypeVar("T", int, str, list)
def double_typed(x: T) -> T:
return x * 2
double_typed(2), double_typed("ab")(4, 'abab')
Now mypy knows that double_typed(2) returns int, not “anything”.
The everyday vocabulary:
from typing import Any
def legacy_parse(text: str) -> Any: ...
def parse(text: str) -> float | None:
try:
return float(text)
except ValueError:
return None
parse("3.14"), parse("hello")(3.14, None)
Any is the type-system escape hatch — “don’t check this.” A value annotated as Any is compatible with every other type, in both directions: a checker neither flags assigning it to anything nor flags assigning anything to it. Use it where you genuinely don’t know — JSON decoding, plug-in interfaces, legacy code being incrementally typed — and prefer a more specific type everywhere else.
def normalize_label(label: int | str) -> str:
if isinstance(label, int):
return str(label)
return label
normalize_label(42), normalize_label("forty-two")('42', 'forty-two')
For collections, since Python 3.9 you can subscript the built-ins directly — list[int], dict[str, int], tuple[int, ...] all work without from typing import List, Dict, Tuple:
def tokenize(text: str) -> list[str]:
return text.upper().split()
tokenize("hello world")['HELLO', 'WORLD']
text: str annotates the input as a string; -> list[str] annotates the return as “a list whose elements are strings.” The body uppercases the input and splits on whitespace, producing ['HELLO', 'WORLD'] — a list of str, exactly as advertised.
from collections.abc import Iterable
def word_lengths(words: Iterable[str]) -> dict[str, int]:
return {w: len(w) for w in words}
word_lengths(["hi", "hello"]){'hi': 2, 'hello': 5}
Iterable[str] is intentionally permissive: a list works, a tuple works, a generator works — anything you can for-over whose elements are strings. dict[str, int] is the return: a dict whose keys are strings and values are ints. The body is a dict comprehension {w: len(w) for w in words} — for each word, map it to its length. Output: {'hi': 2, 'hello': 5}.
def divmod_pair(a: int, b: int) -> tuple[int, int]:
return divmod(a, b)
divmod_pair(17, 5)(3, 2)
tuple[int, int] annotates a fixed-length, two-element tuple where both slots are ints. divmod(17, 5) returns the pair (quotient, remainder) — (3, 2) here, because 17 = 3 * 5 + 2. The annotation tells the type checker that callers can safely unpack into two integer-typed names: q, r = divmod_pair(17, 5) will see q: int, r: int.
tuple[int, int] is a fixed-length tuple. tuple[int, ...] (with the literal ...) is a variable-length homogeneous tuple.
from __future__ import annotations
Putting this at the top of a module turns all annotations into strings (PEP 563 — postponed evaluation). Two practical wins: forward references work without quoting (def f() -> "MyClass": becomes def f() -> MyClass:), and there is zero runtime cost for evaluating the annotation expression. Tools like mypy still type-check normally; introspection-heavy libraries (pydantic, dataclasses in some modes) need to opt in.
When a parameter is a function, you’d like the type hint to record both what arguments it takes and what it returns, so a wrong-shaped callback gets caught. That’s Callable:
from collections.abc import Callable
def apply(func: Callable[[int], str], value: int) -> str:
return func(value)
apply(str, 42)'42'
Walking through the Callable syntax:
Callable[[int], str] reads “a callable that takes one int and returns a str.” The first bracketed list is the argument types (in order); the second item is the return type.Callable[[], str] — empty inner list. For a variadic callable where you don’t want to spell the args, write Callable[..., str] with the literal ....apply(str, 42) type-checks because str is Callable[[int], str]-compatible (calling str(42) produces "42"). Passing apply(len, 42) would still work at runtime for many inputs, but mypy would object — len returns int, not str.For parameters, prefer the most abstract type that supports the operations you need. Iterable is the most permissive; Sequence adds indexing; Mapping is the read-only dict shape.
from collections.abc import Sequence, Mapping, Iterable
def column(matrix: Sequence[Sequence[float]], i: int) -> list[float]:
return [row[i] for row in matrix]
column([[1, 2], [3, 4], [5, 6]], 0)[1, 3, 5]
Walking through the type chosen here:
Sequence[Sequence[float]] says “a thing you can iterate and index, whose elements are themselves indexable sequences of floats.” list[list[float]] would also work for the test call, but Sequence is more permissive — a tuple of tuples would be accepted too.Sequence (not Iterable) on the inner type because row[i] needs indexing. Picking the minimum abstraction the body actually needs is the discipline.list[float] — concrete, because callers benefit from knowing it’s a real list they can index, append to, and pass on.def total(items: Iterable[int]) -> int:
return sum(items)
total([1, 2, 3]), total(range(4)), total(n*n for n in range(5))(6, 6, 30)
total accepts any iterable — list, range, generator. If the parameter were list[int], the generator wouldn’t type-check.
Mapping[K, V] is the read-only dict shape — accept a dict, a ChainMap, anything with __getitem__ and keys:
def lookup(table: Mapping[str, int], key: str) -> int:
return table.get(key, 0)
lookup({"a": 1, "b": 2}, "a")1
The opposite rule applies to return types: be concrete. Returning list[int] is more useful than returning Iterable[int], because the caller can rely on indexing and length.
TypeVar and parameterized genericsfirst(seq) should return the same kind of thing the sequence holds — int in, int out; str in, str out. Annotating it as Any -> Any loses that information; spelling it out as int -> int plus str -> str plus everything else is impossible. TypeVar lets you write it once and let the type checker fill in the type at each call:
from typing import TypeVar
from collections.abc import Sequence
T = TypeVar("T")
def first(seq: Sequence[T]) -> T:
return seq[0]
first([1, 2, 3]), first(["a", "b"])(1, 'a')
Walking through this:
T = TypeVar("T") introduces a type variable — a placeholder a checker will resolve at each call site. The string "T" must match the variable name; that’s just how TypeVar is spelled.Sequence[T] says “a sequence whose elements are some type T.” -> T says “I return that same type.”first([1, 2, 3]), the checker matches Sequence[T] against list[int] and infers T = int, so the return type is int. At first(["a", "b"]) it infers T = str. The single function is generic over the element type.A constrained TypeVar lists allowed types — the value must be exactly one of them:
from decimal import Decimal
from fractions import Fraction
from collections.abc import Iterable
NumberT = TypeVar("NumberT", float, Decimal, Fraction)
def average(data: Iterable[NumberT]) -> NumberT:
items = list(data)
return sum(items) / len(items)
average([1.0, 2.0, 3.0])2.0
A bounded TypeVar (using bound=) accepts the type or any subclass:
from numbers import Real
RealT = TypeVar("RealT", bound=Real)
def clamp(x: RealT, lo: RealT, hi: RealT) -> RealT:
return max(lo, min(x, hi))
clamp(5, 0, 10), clamp(3.14, 0.0, 1.0)(5, 1.0)
Use bound= when “any subclass of X” is the intent; use the constrained form when only a closed set of types is allowed.
Protocol typestyping.Protocol is the type-system spelling of duck typing. You declare an interface; any class with matching methods is compatible — no inheritance required. Build it in three small steps.
Step 1: declare a protocol. A Protocol subclass with method signatures (no bodies — ... is the placeholder) defines a shape:
from typing import Protocol
class Drawable(Protocol):
def draw(self) -> None: ...
class Circle:
def draw(self) -> None:
print("Drawing circle")
def render(shape: Drawable) -> None:
shape.draw()
render(Circle())Drawing circle
class Drawable(Protocol): declares a structural type — anyone with the listed methods qualifies.def draw(self) -> None: ... is a method signature. The ... is the body placeholder — the protocol describes the shape, never executes.Circle does not inherit from Drawable. It just happens to have a matching draw method, so render(Circle()) is accepted by the type checker.Step 2: see that isinstance does not work yet. A bare Protocol is a static-only contract. Try to use it at runtime and Python refuses:
isinstance(Circle(), Drawable)--------------------------------------------------------------------------- TypeError Traceback (most recent call last) Cell In[19], line 1 ----> 1 isinstance(Circle(), Drawable) File /opt/hostedtoolcache/Python/3.13.13/x64/lib/python3.13/typing.py:2132, in _ProtocolMeta.__instancecheck__(cls, instance) 2126 return _abc_instancecheck(cls, instance) 2128 if ( 2129 not getattr(cls, '_is_runtime_protocol', False) and 2130 not _allow_reckless_class_checks() 2131 ): -> 2132 raise TypeError("Instance and class checks can only be used with" 2133 " @runtime_checkable protocols") 2135 if _abc_instancecheck(cls, instance): 2136 return True TypeError: Instance and class checks can only be used with @runtime_checkable protocols
mypy and other static checkers happily verify Drawable compatibility without ever calling isinstance. Runtime introspection is opt-in.Step 3: opt in with @runtime_checkable. When you genuinely need the runtime check (dispatching on shape, validating untyped input at a boundary), apply the decorator:
from typing import runtime_checkable
@runtime_checkable
class Drawable(Protocol):
def draw(self) -> None: ...
class Square:
def draw(self) -> None:
print("Drawing square")
isinstance(Square(), Drawable), isinstance("not a shape", Drawable)(True, False)
@runtime_checkable tells Python “build the introspection machinery so isinstance works.”isinstance (only mypy catches that).@runtime_checkable only at the boundary between typed and untyped code.The general pattern: Protocol is duck typing made checkable. You write the shape you depend on; any class that fits is accepted; nothing has to inherit from anything. We will return to protocols, ABCs, and the line between them in Chapter 25.
“Types are defined by supported operations” — this is duck typing expressed as a type system. The Protocol class formalizes duck typing: define the interface, and any class that implements those methods satisfies the protocol, without inheriting from anything.
Repository[ModelT] over any recordA repository is the standard “store and look up records by id” abstraction. Building a generic one is the canonical workout for Protocol, TypeVar with a bound=, and the modern PEP 695 generic syntax.
Step 1: a Protocol for “has an id.” Any record we want to store must expose an integer id. A Protocol says exactly that without forcing the model classes to inherit anything:
from typing import Protocol
class HasId(Protocol):
id: int
# Any class with an `id: int` is a HasId — no inheritance needed.
from dataclasses import dataclass
@dataclass
class User:
id: int
name: str
@dataclass
class Order:
id: int
total: float
User(1, "Alice"), Order(101, 99.99)(User(id=1, name='Alice'), Order(id=101, total=99.99))
HasId declares an attribute (not a method) protocol. Any class with an id: int slot is a HasId — User and Order qualify without ever importing HasId. That’s structural typing applied to fields rather than methods.
Step 2: a generic Repository parameterised over the model type. The PEP 695 [ModelT: HasId] syntax declares a type parameter scoped to the class — ModelT must be HasId-compatible:
class Repository[ModelT: HasId]:
def __init__(self) -> None:
self._store: dict[int, ModelT] = {}
def save(self, item: ModelT) -> ModelT:
self._store[item.id] = item
return item
def get(self, id: int) -> ModelT | None:
return self._store.get(id)
def all(self) -> list[ModelT]:
return list(self._store.values())
users: Repository[User] = Repository()
users.save(User(1, "Alice"))
users.save(User(2, "Bob"))
[users.get(1), users.get(99), users.all()][User(id=1, name='Alice'),
None,
[User(id=1, name='Alice'), User(id=2, name='Bob')]]
[ModelT: HasId] reads “a type parameter ModelT bounded by HasId.” Inside the class, every ModelT annotation refers to the same type — so save returns whatever ModelT is, and get returns ModelT | None. A type checker treats users.save(User(1, "Alice")) as User-typed without us writing User twice, and users.save(Order(...)) would be flagged as a type error.
Step 3: separate repositories for separate models — no code duplication. The same generic class produces fully-typed Repository[User], Repository[Order], etc., each with its own storage:
orders: Repository[Order] = Repository()
orders.save(Order(101, 99.99))
orders.save(Order(102, 49.50))
[users.all(), orders.all(),
# Type-level check: users and orders are separate dicts
users.get(101) is None][[User(id=1, name='Alice'), User(id=2, name='Bob')],
[Order(id=101, total=99.99), Order(id=102, total=49.5)],
True]
users and orders are separate Repository instances with different element types — mypy would flag users.save(Order(...)) as Order is incompatible with User. The _store dicts are independent (no cross-contamination), and users.get(101) returns None because that id was only saved on orders.
The build threads the chapter’s three biggest ideas through one concrete example: a Protocol to describe the shape of the model (Step 1), a TypeVar (via the modern [ModelT: HasId] syntax) so the same class works for any conforming model (Step 2), and the structural-typing payoff that you get strong, model-specific types without any inheritance from a base class (Step 3).
Annotate tokenize. Add type hints to tokenize from the chapter so that the input is “anything that can be .upper().split()-ed and returns a list of strings.” Check with mypy --strict.
Optional vs | None. Convert a function with Optional[int] parameter to use the int | None syntax. Are the two equivalent under mypy?
A bounded TypeVar. Write def median(values: Iterable[T]) -> T where T is bounded to int | float | Decimal. Make sure passing a list of strings is rejected by the type checker.
A Protocol for sized. Define a Sized protocol with a __len__ method. Annotate def percent_full(x: Sized, capacity: int) -> float to compute len(x) / capacity * 100.
When Any is honest. Write a function that accepts an arbitrary JSON-decoded value and returns its depth. Annotate the parameter as Any. Why is Any more honest here than int | str | list | dict | None?
Type hints are gradual: you add them where they pay rent. The everyday vocabulary — list[T], tuple[T, ...], Optional, Iterable, TypeVar, Protocol — is enough for nearly everything. We’ll revisit the harder parts (variance, TypedDict, overload) in Chapter 27 once classes and protocols are on the table.
Next, Chapter 21 is where first-class functions earn their keep at scale: decorators, closures, and the standard-library decorators that you’ll use every day.