
Chapter Outline
Chapter 13: Python Type Hints and Generics
Python is dynamically typed — variables don’t require explicit types. This flexibility is powerful, but as codebases grow, it can also become risky: errors may appear only at runtime, and function signatures may be ambiguous.
To improve safety and readability, Python introduced type hints (PEP 484). These don’t enforce types at runtime but can be checked using external tools like mypy, IDEs, or runtime validators like pydantic.
In this chapter, we’ll explore:
- Basic and advanced type hints
- The
typingmodule and its constructs - Generics and reusable type-safe patterns
- Advanced typing features (
Protocol,TypedDict,Literal,NewType, etc.) - Practical examples in workflows and data validation
13.1 Function and Variable Annotations
Function Annotations
You can annotate parameters and the return type of a function.
pythondef greet(name: str, age: int) -> str:return f"Hello, {name}. You are {age} years old."
name: str→namemust be astr.age: int→agemust be anint.-> str→ the function returns astr.
If you call it incorrectly:
pythongreet("Alice", "thirty") # mypy or IDE will warn: expected int, got str
Python won’t raise an error at runtime, but type checkers like mypy will flag it.
mypyneeds to be installed and run on your codebase to identify annotation errors. Visit the mypy site for additional instructions.
Default Values
pythondef increment(x: int, step: int = 1) -> int:return x + step
Optional Parameters
Use Optional[type] (or Union[type, None]) when a parameter can be None.
pythonfrom typing import Optionaldef find_user(user_id: Optional[int]) -> str:if user_id is None:return "Guest"return f"User {user_id}"u_id = Noneprint(find_user(u_id))
Variable Annotations
Variables can be annotated independently of assignment.
pythonno_value: int # `no_value` assigned a type but not initializedname: str = "Alice" # `name` initialized to the value "Alice"age: int = 30 # `age` initialized to 30scores: list[int] = [95, 88, 76] # `scores` is a list of integers
Without Initialization
You can annotate without assigning a value. Useful for class attributes:
pythonfrom typing import Optionalclass User:id: intname: stremail: Optional[str] = None # This allows proper stringificationdef __str__(self) -> str:return f"User(id={self.id}, name={self.name}, email={self.email or 'N/A'})"def __repr__(self) -> str:return f"User(id={self.id!r}, name={self.name!r}, email={self.email!r})"
- Notice the
!r→ it callsrepr()on each attribute, so strings are quoted properly.
Forward References
For recursive data structures, use strings as type hints:
pythonclass Node:value: intnext: "Node | None" = None # quotes allow forward referencedef __str__(self) -> str:return f"Node(value={self.value}, next={self.next})"node_1 = Node()node_1.value = 100node_2 = Node()node_2.value = 200node_1.next = node_2print(node_1)
Output:
bashNode(value=100, next=Node(value=200, next=None))
Introspection with __annotations__
Python stores annotations in a special dictionary:
pythondef add(x: int, y: int) -> int:return x + yprint(add.__annotations__)# {'x': <class 'int'>, 'y': <class 'int'>, 'return': <class 'int'>}print(Node.__annotations__)# {'value': <class 'int'>, 'next': 'None | Node'}
Only things that are annotated are listed here. Un-annotated items are not listed within the dictionary.
13.2 Collections and the typing Module
Python’s collections give you fast, battle‑tested containers; the typing module (plus collections.abc) lets you describe those containers precisely so tools like mypy can catch bugs before runtime.
Core collections you’ll actually use
list: ordered, mutable sequencetuple: fixed-size, immutable sequenceset,frozenset: unique items, fast membership testsdict: key–value mappingdeque(collections.deque): fast appends/pops from both endsdefaultdict/Counter(collections): counting & grouped data
pythonfrom collections import deque, defaultdict, Counterdq = deque([1, 2, 3]); dq.appendleft(0)freq = Counter("bananas") # Counter({'a': 3, 'n': 2, 'b': 1, 's': 1})group = defaultdict(list)group["errors"].append("E001")
Type hinting collections (modern syntax)
In Python 3.9+, use built‑in generics: list[int], dict[str, float], etc.
Prefer protocol/ABC types for function parameters (accept more inputs), and concrete types for returns (promise what you return).
python# Good parameter types: accept any sequence/mapping; return concrete types.from collections.abc import Sequence, Mappingdef average(nums: Sequence[float]) -> float:return sum(nums) / len(nums)def invert(d: Mapping[str, int]) -> dict[int, str]:return {v: k for k, v in d.items()}values: list[float] = [1.0, 2.0, 3.5]print(average(values)) # 2.166...
Mutable vs. read‑only views
Use abstract bases from collections.abc to communicate mutability expectations:
Sequence[T](read‑only indexing/iterating) vsMutableSequence[T]Mapping[K, V](read‑only) vsMutableMapping[K, V]
pythonfrom collections.abc import MutableMappingdef touch(cache: MutableMapping[str, int], key: str) -> None:cache[key] = cache.get(key, 0) + 1
Nested / compound types
The following code defines type aliases using Python’s typing syntax.
pythonMatrix = list[list[float]] # 2D listAdjList = dict[str, set[str]] # graph adjacency listRecords = list[tuple[int, str | None]] # union in tuples (3.10+ `|` syntax)my_matrix: Matrix = [[1.2, 3.3, 9.5], [8.7, 4.5, 5.6]]adjacency: AdjList = { 'one': { '1', '2', '3'}, 'two': { '1', '2', '3'} }my_rec: Records = [(1, 'one'), (2, 'two'), (3, 'three')]
Deques, default dicts, and typing
Runtime types live in collections, but you annotate with built‑ins or typing aliases when needed:
pythonfrom collections import deque, defaultdictwork: deque[str] = deque()index: dict[str, list[int]] = defaultdict(list)work.append("task-1")index["x"].append(42)
defaultdict: A dictionary subclass that automatically provides a default value for missing keys.- Since
"x"doesn’t exist yet,defaultdictcreatesindex["x"] = []automatically.
Typed dictionaries and lightweight records
When a dict has a fixed shape, use TypedDict. For tuple‑like records with names, use NamedTuple or dataclass.
pythonfrom typing import TypedDict, NamedTupleclass UserTD(TypedDict):id: intname: stremail: str | Noneclass Point(NamedTuple):x: floaty: floatdef make_user() -> UserTD:return {"id": 1, "name": "Alice", "email": None}
Iterators, generators, and streams
Use Iterable[T] when you only need to iterate, Iterator[T] when you also consume step‑by‑step, and Generator[Y, S, R] for full generator contracts.
pythonfrom collections.abc import Iterable, Iteratordef only_ints(xs: Iterable[int]) -> list[int]:return [x for x in xs if isinstance(x, int)]def countdown(n: int) -> Iterator[int]:while n:yield nn -= 1for i in countdown(10):print(i) # Prints 10 → 0
Validating with Pydantic
When “dicts of stuff” come from the outside (JSON, APIs), use Pydantic to validate and coerce.
pythonfrom pydantic import BaseModel, EmailStr, ValidationErrorfrom typing import Optionalclass UserModel(BaseModel):id: intname: stremail: Optional[EmailStr] = Nonetags: list[str] = []try:u = UserModel(id="1", name="Alice", email="@example.com", tags=("a", "b"))# id coerced to int; tags coerced to listexcept ValidationError as e:print(e.json())
The above code raises a validation error because of the email address format.
13.3 Callable and Type Aliases
When building larger Python applications, two recurring challenges come up:
- Functions as parameters: You may want to pass a function into another function (e.g., a strategy or callback). How do you type hint such callables?
- Repetitive, complex type hints:
You may have a long, repeated type annotation like
dict[str, list[tuple[int, float]]]. How do you avoid clutter and improve readability?
The solutions: Callable and Type Aliases.
Callable
The typing.Callable type lets you describe a function’s signature — the shape of its parameters and its return type.
Basic Example
pythonfrom typing import Callabledef apply_twice(func: Callable[[int], int], x: int) -> int:"""Apply a function to a value twice."""return func(func(x))def square(n: int) -> int:return n * nprint(apply_twice(square, 3)) # (3^2)^2 = 81
Callable[[int], int]means:- A function taking one int argument,
- Returning an int.
With Multiple Parameters
pythondef operate(func: Callable[[int, int], float], a: int, b: int) -> float:return func(a, b)def divide(a: int, b: int) -> float:return a / bprint(operate(divide, 10, 2)) # 5.0
Any Parameters (...)
pythondef run_any(func: Callable[..., str]) -> str:"""Callable with any number of arguments, but must return str."""return func("Hello", "World")def join_strings(a: str, b: str) -> str:return a + " " + bprint(run_any(join_strings)) # Hello World
Using Callable in Classes (Strategy Pattern)
You can use Callable for strategies, hooks, and callbacks.
pythonfrom typing import Callableclass Calculator:def __init__(self, operation: Callable[[int, int], int]):self.operation = operationdef compute(self, a: int, b: int) -> int:return self.operation(a, b)# Different strategiesadd = lambda x, y: x + ymul = lambda x, y: x * ycalc_add = Calculator(add)calc_mul = Calculator(mul)print(calc_add.compute(2, 3)) # 5print(calc_mul.compute(2, 3)) # 6
Here Callable[[int, int], int] makes the class type-safe while still flexible.
Type Aliases
Sometimes type annotations become long and hard to read. A type alias gives them a readable name.
Simple Alias
pythonUserId = intScore = floatdef record_score(user: UserId, score: Score) -> None:print(f"User {user} scored {score}")record_score(101, 9.5)
This improves readability, even though UserId is just an int.
Complex Aliases
pythonfrom typing import Callable# Graph typesNode = strAdjList = dict[Node, set[Node]]# Function typeTransform = Callable[[str], str]def apply_pipeline(data: list[str], steps: list[Transform]) -> list[str]:for step in steps:data = [step(x) for x in data]return data# Usagesteps: list[Transform] = [str.strip, str.upper]print(apply_pipeline([" hello ", " world "], steps))# ['HELLO', 'WORLD']
13.4 Generics
Generics in Python allow us to write flexible and type-safe code that works across many types without duplicating logic. They’re the foundation for reusable data structures (lists, queues, trees), frameworks, and APIs.
Why Generics?
Without generics, you either:
- Hard-code a specific type:
→ Only works forpythondef first_str(items: list[str]) -> str:return items[0]list[str]. - Use
Any, which removes type safety:pythonfrom typing import Anydef first_any(items: list[Any]) -> Any:return items[0]
With Generics, you can write one function that works for all item types, while keeping type safety.
Generic Functions
pythonfrom typing import TypeVarT = TypeVar("T") # placeholder for any typedef first(items: list[T]) -> T:"""Return the first element of a list."""return items[0]print(first([1, 2, 3])) # infers T=int → returns intprint(first(["a", "b", "c"])) # infers T=str → returns str
Here, T is a type variable that represents “the type of list elements.”
mypy (or Pyright) checks that the return type matches the input type.
Generic Classes
pythonfrom typing import Genericclass Box(Generic[T]):"""A generic container that holds one item of any type."""def __init__(self, value: T):self.value = valuedef get(self) -> T:return self.valueint_box = Box(42) # Box[int]str_box = Box("hello") # Box[str]print(int_box.get()) # intprint(str_box.get()) # str
Box[int]→ a box for integers.Box[str]→ a box for strings.
Frameworks like SQLAlchemy, Django ORM, and FastAPI use similar patterns internally.
Bounded Type Variables
You can restrict type variables to a base class.
pythonfrom typing import Protocolclass Comparable(Protocol):def __lt__(self, other: object) -> bool: ...U = TypeVar("U", bound=Comparable)def minimum(a: U, b: U) -> U:return a if a < b else bprint(minimum(3, 7)) # works (int is Comparable)print(minimum("a", "b")) # works (str is Comparable)
This guarantees that minimum only accepts types that implement <.
Multiple TypeVars
pythonK = TypeVar("K")V = TypeVar("V")def get_or_default(d: dict[K, V], key: K, default: V) -> V:return d.get(key, default)scores: dict[str, int] = {"alice": 90}print(get_or_default(scores, "bob", 0)) # returns int
Aliases with Generics
pythonfrom typing import TypeVarT = TypeVar("T")Matrix = list[list[T]]def transpose(matrix: Matrix[T]) -> Matrix[T]:return [list(row) for row in zip(*matrix)]print(transpose([[1, 2, 3], [4, 5, 6]]))# [[1, 4], [2, 5], [3, 6]]
Using Generics with mypy
Run type checks:
bashmypy your_file.py
Example file:
pythonnums = [1, 2, 3]print(first(nums)) # OKprint(first("hello")) # Error: str is not a list
Mypy enforces that only lists are passed.
Generics with Pydantic
Pydantic models also support generics, useful for API responses.
pythonfrom typing import Generic, TypeVar, Listfrom pydantic import BaseModelT = TypeVar("T")class ApiResponse(BaseModel, Generic[T]):success: booldata: list[T]class User(BaseModel):id: intname: str# Use generics in practiceresp = ApiResponse[User](success=True, data=[User(id=1, name="Alice")])print(resp.json())
Output:
json{"success": true, "data": [{"id": 1, "name": "Alice"}]}
Frameworks like FastAPI leverage this to define generic API response types.
13.5 Advanced Typing Patterns
Protocols: Structural Subtyping
pythonfrom typing import Protocolclass Drivable(Protocol):def drive(self) -> None: ...class Car:def drive(self) -> None: print("Car driving")def start_journey(vehicle: Drivable):vehicle.drive()car = Car()print(start_journey(car))
TypedDict: JSON-like schemas
It's a special construct that adds type hints to a dictionary.
pythonfrom typing import TypedDictclass CarSpec(TypedDict):make: strmodel: stryear: intelectric: boolmy_car: CarSpec = { "make": "Ford", "model": "Mustang", "year": 1967, "electric": False }
Literal: Restricted values
pythonfrom typing import Literaldef paint(color: Literal["red", "blue"]): ...
NewType: Strong semantic types
pythonfrom typing import NewTypeUserId = NewType('UserId', int)Email = NewType('Email', str)class User:id: UserIdemail: Email
13.6 Tools
So far we have talked about mypy and pydantic in this chapter:
- Use
mypyto type check your code statically. - Use
pydanticto enforce data types at runtime, unlike type hints.
13.7 Assignment
In this assignment, you will apply type hints, generics, and runtime validation to a simple workflow.
Requirements
- Define a generic
Box[T]class that can hold any type (int,str,dict, etc.). - Create a function
load_records(file: str) -> list[dict[str, str]]that loads a CSV file and returns records. Add proper type hints. - Define a type alias
Record = dict[str, str]. - Create a callable pipeline:
Write two transforms:pythonfrom typing import CallableTransform = Callable[[Record], Record]strip_whitespace(record: Record) -> Recorduppercase_name(record: Record) -> Record
- Use
mypyto check your code for type safety. - Use Pydantic to validate a record schema:
pythonclass UserRecord(BaseModel):id: intname: stremail: EmailStr
Hints
- Use
list[dict[str, str]]for collections of records. - You can simulate CSV input with a list of dicts if you don’t want to read files.
mypy file.pywill report any type mismatches.- Try invalid inputs (e.g.,
{"id": "abc"}) to see how Pydantic enforces runtime validation.
Check your understanding
Test your knowledge of Python Type Hints and Generics