Chapter 26: Type Hints and the typing Module
Python is dynamically typed — variables don't have fixed types, and Python doesn't check types at runtime. But large codebases written without type information are hard to maintain. You can't tell what a function expects just by reading its signature, editors can't autocomplete reliably, and bugs slip through that a type checker would have caught instantly.
Type hints fix this. They don't change how your program runs, but they tell humans and tools what types are expected. Your editor catches mistakes before you run the code. Your teammates understand your API without reading the implementation. Refactoring becomes safe.
This chapter teaches you how to read and write type hints, how to use the typing module, and how to run mypy to check your code statically.
The Basics
Type hints use the : syntax for variables and parameters, and -> for return types:
# Variable annotations
name: str = "Alice"
age: int = 30
score: float = 95.5
active: bool = True
# Without a value — just declaring the type
user_id: int
# Function annotations
def greet(name: str) -> str:
return f"Hello, {name}!"
def add(a: int, b: int) -> int:
return a + b
def save_user(name: str, age: int) -> None:
print(f"Saving {name}, age {age}")
Type hints are stored in __annotations__ but Python does not enforce them at runtime:
def double(n: int) -> int:
return n * 2
print(double(5)) # 10 — works
print(double("ha")) # 'haha' — Python doesn't complain!
print(double.__annotations__)
# {'n': <class 'int'>, 'return': <class 'int'>}
Runtime enforcement requires a type checker like mypy or a library like pydantic. The hints themselves are documentation — very useful documentation.
Built-in Collection Types (Python 3.9+)
From Python 3.9 onwards, you can use built-in types directly as generic types:
# Python 3.9+
def process(items: list[int]) -> list[str]:
return [str(i) for i in items]
def merge(a: dict[str, int], b: dict[str, int]) -> dict[str, int]:
return {**a, **b}
def first_two(items: tuple[int, int]) -> int:
return items[0] + items[1]
def unique(items: list[str]) -> set[str]:
return set(items)
Before Python 3.9, you imported these from typing:
# Python 3.7 / 3.8 — use typing module
from typing import List, Dict, Tuple, Set
def process(items: List[int]) -> List[str]:
return [str(i) for i in items]
From Python 3.10+, you can use X | Y instead of Union[X, Y]:
# Python 3.10+
def parse(value: str | int | None) -> float:
if value is None:
return 0.0
return float(value)
Optional — Values That Can Be None
Optional[X] means the value is either X or None. It's very common for function parameters with defaults and return values that might fail:
from typing import Optional
def find_user(user_id: int) -> Optional[dict]:
users = {1: {"name": "Alice"}, 2: {"name": "Bob"}}
return users.get(user_id) # returns dict or None
def greet_user(name: Optional[str] = None) -> str:
if name is None:
return "Hello, stranger!"
return f"Hello, {name}!"
user = find_user(1)
if user is not None:
print(user["name"]) # Alice
print(greet_user()) # Hello, stranger!
print(greet_user("Alice")) # Hello, Alice!
In Python 3.10+, Optional[X] is equivalent to X | None:
def find_user(user_id: int) -> dict | None: # cleaner
...
Union — Multiple Possible Types
Union[X, Y] means the value can be either X or Y:
from typing import Union
def stringify(value: Union[int, float, bool]) -> str:
return str(value)
# Python 3.10+ shorthand
def stringify(value: int | float | bool) -> str:
return str(value)
Any — Opt Out of Type Checking
Any is compatible with every type. Use it when you genuinely can't or don't want to specify a type — but use it sparingly. Every Any is a gap in your type coverage.
from typing import Any
def log(value: Any) -> None:
print(repr(value))
def passthrough(data: Any) -> Any:
return data
Callable — Typing Functions
Callable[[ArgTypes], ReturnType] describes a callable (function, lambda, class with __call__):
from typing import Callable
def apply(func: Callable[[int], int], value: int) -> int:
return func(value)
def apply_two(func: Callable[[int, int], str], a: int, b: int) -> str:
return func(a, b)
# A callable with no arguments
def run(callback: Callable[[], None]) -> None:
callback()
# Callable with any arguments (use ... as parameter list)
def call_anything(func: Callable[..., int]) -> int:
return func()
Sequence, Iterable, Mapping — Accept More Types
Being specific about list means your function only works with lists. Using abstract types makes functions more flexible:
from typing import Sequence, Iterable, Mapping, MutableMapping
# Only works with lists
def sum_list(items: list[int]) -> int:
return sum(items)
# Works with lists, tuples, strings, ranges — anything iterable
def sum_any(items: Iterable[int]) -> int:
return sum(items)
# Works with lists, tuples — anything with len() and indexing
def first(items: Sequence[int]) -> int:
return items[0]
# Read-only dict-like access
def lookup(data: Mapping[str, int], key: str) -> int:
return data.get(key, 0)
# Writable dict-like access
def update(data: MutableMapping[str, int], key: str, value: int) -> None:
data[key] = value
The hierarchy to remember: Iterable ⊃ Sequence ⊃ list/tuple. Accept the most general type that meets your needs; return the most specific type you produce.
TypeVar — Generic Functions
A TypeVar is a placeholder for "some type, I don't know which one yet." It lets you write functions that work on any type while still being type-safe:
from typing import TypeVar
T = TypeVar("T")
def first(items: list[T]) -> T:
return items[0]
def last(items: list[T]) -> T:
return items[-1]
def identity(value: T) -> T:
return value
The type checker knows that if you call first([1, 2, 3]), the return type is int. If you call first(["a", "b"]), the return type is str. The T is inferred from what you pass in.
You can constrain a TypeVar to specific types:
from typing import TypeVar
Numeric = TypeVar("Numeric", int, float)
def add(a: Numeric, b: Numeric) -> Numeric:
return a + b
print(add(1, 2)) # 3 (int)
print(add(1.5, 2.5)) # 4.0 (float)
# add("a", "b") — mypy would flag this
Generic Classes
You can make your own generic classes using Generic[T]:
from typing import TypeVar, Generic, Optional
T = TypeVar("T")
class Stack(Generic[T]):
"""A typed stack that holds items of type 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("Stack is empty.")
return self._items.pop()
def peek(self) -> Optional[T]:
return self._items[-1] if self._items else None
def __len__(self) -> int:
return len(self._items)
def __bool__(self) -> bool:
return bool(self._items)
def __repr__(self) -> str:
return f"Stack({self._items!r})"
int_stack: Stack[int] = Stack()
int_stack.push(1)
int_stack.push(2)
int_stack.push(3)
print(int_stack.peek()) # 3
print(int_stack.pop()) # 3
print(len(int_stack)) # 2
str_stack: Stack[str] = Stack()
str_stack.push("hello")
str_stack.push("world")
print(str_stack.pop()) # world
Protocol — Structural Subtyping (Duck Typing, but Typed)
Python uses duck typing — if it has the right methods, it works. Protocol brings this into the type system. A class satisfies a Protocol if it has the required methods, without explicitly inheriting from it:
from typing import Protocol, runtime_checkable
@runtime_checkable
class Drawable(Protocol):
def draw(self) -> str:
...
class Circle:
def __init__(self, radius: float) -> None:
self.radius = radius
def draw(self) -> str:
return f"○ Circle(r={self.radius})"
class Rectangle:
def __init__(self, w: float, h: float) -> None:
self.w, self.h = w, h
def draw(self) -> str:
return f"□ Rectangle({self.w}x{self.h})"
class Triangle:
def draw(self) -> str:
return "△ Triangle"
def render_all(shapes: list[Drawable]) -> None:
for shape in shapes:
print(shape.draw())
shapes = [Circle(5), Rectangle(3, 4), Triangle()]
render_all(shapes)
# Check at runtime (because of @runtime_checkable)
print(isinstance(Circle(1), Drawable)) # True
print(isinstance("not a shape", Drawable)) # False
Circle, Rectangle, and Triangle never inherit from Drawable. They just happen to have a draw() method. Protocol captures that relationship without forcing inheritance.
A richer protocol example:
from typing import Protocol
class Comparable(Protocol):
def __lt__(self, other: "Comparable") -> bool: ...
def __eq__(self, other: object) -> bool: ...
class Saveable(Protocol):
def save(self) -> None: ...
def load(self, path: str) -> None: ...
class Sizeable(Protocol):
def __len__(self) -> int: ...
NamedTuple — Typed Tuples
NamedTuple gives you a tuple with named fields and type annotations — immutable, lightweight, and inspectable:
from typing import NamedTuple
class Point(NamedTuple):
x: float
y: float
label: str = "" # default value
class Employee(NamedTuple):
name: str
dept: str
salary: float
active: bool = True
p = Point(3.0, 4.0, "origin")
print(p) # Point(x=3.0, y=4.0, label='origin')
print(p.x) # 3.0
print(p[1]) # 4.0 — still a tuple
print(p._asdict()) # {'x': 3.0, 'y': 4.0, 'label': 'origin'}
# Can unpack like a regular tuple
x, y, label = p
emp = Employee("Alice", "Eng", 95_000)
print(emp) # Employee(name='Alice', dept='Eng', salary=95000, active=True)
# Works with sorted, min, max
employees = [
Employee("Bob", "HR", 68_000),
Employee("Alice", "Eng", 95_000),
Employee("Carlos", "Sales", 74_000),
]
top = max(employees, key=lambda e: e.salary)
print(top.name) # Alice
Use NamedTuple when you want a simple, immutable data carrier with field names. Use @dataclass when you need mutability, inheritance, or custom methods.
TypedDict — Typed Dictionaries
TypedDict describes a dictionary with specific string keys and typed values — useful for JSON-like data:
from typing import TypedDict, Required, NotRequired
class Address(TypedDict):
street: str
city: str
state: str
zip: str
class User(TypedDict):
id: int
name: str
email: str
address: Address
# Optional keys (Python 3.11+)
class Config(TypedDict, total=False):
debug: bool # optional
timeout: int # optional
host: str # optional
# Mix required and optional (Python 3.11+)
class ServerConfig(TypedDict):
host: Required[str]
port: Required[int]
debug: NotRequired[bool]
timeout: NotRequired[float]
def send_welcome(user: User) -> str:
return f"Welcome, {user['name']}! Your ID is {user['id']}."
alice: User = {
"id": 1,
"name": "Alice",
"email": "alice@example.com",
"address": {
"street": "123 Main St",
"city": "Springfield",
"state": "IL",
"zip": "62701",
},
}
print(send_welcome(alice))
TypedDict is a type-checking construct only — at runtime, it's just a plain dict. mypy uses the annotation to catch key typos and wrong value types.
Literal Types
Literal restricts a value to specific constants:
from typing import Literal
Mode = Literal["r", "w", "a", "r+", "w+"]
def open_file(path: str, mode: Mode = "r") -> None:
with open(path, mode) as f:
pass
open_file("data.txt", "r") # OK
open_file("data.txt", "x") # mypy flags this — "x" not in Literal
Direction = Literal["north", "south", "east", "west"]
Status = Literal["pending", "active", "closed"]
LogLevel = Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
def move(direction: Direction, steps: int) -> None:
print(f"Moving {direction} {steps} step(s)")
move("north", 3) # OK
move("up", 1) # mypy: Argument 1 to "move" has incompatible type "Literal['up']"
Final — Constants
Final marks a variable as constant — it cannot be reassigned:
from typing import Final
MAX_RETRIES: Final = 3
DEFAULT_HOST: Final[str] = "localhost"
PI: Final[float] = 3.14159265358979
MAX_RETRIES = 5 # mypy: Cannot assign to final name "MAX_RETRIES"
Running mypy
Install mypy and run it on your code:
pip install mypy
mypy my_script.py
A simple example to show what mypy catches:
# buggy.py
def greet(name: str) -> str:
return f"Hello, {name}!"
def add(a: int, b: int) -> int:
return a + b
result = greet(42) # mypy: Argument 1 to "greet" has incompatible type "int"; expected "str"
total = add("a", "b") # mypy: Argument 1 to "add" has incompatible type "str"; expected "int"
$ mypy buggy.py
buggy.py:7: error: Argument 1 to "greet" has incompatible type "int"; expected "str" [arg-type]
buggy.py:8: error: Argument 1 to "add" has incompatible type "str"; expected "int" [arg-type]
Found 2 errors in 1 file (checked 1 source file)
Common mypy configuration in mypy.ini or pyproject.toml:
# mypy.ini
[mypy]
python_version = 3.12
strict = True
ignore_missing_imports = True
strict enables the strictest checks: no implicit Any, no untyped function definitions, no unannotated variables. Good for new projects.
Type Aliases
Give complex types a readable name:
from typing import TypeAlias
# Simple alias
JSON: TypeAlias = dict[str, "JSON"] | list["JSON"] | str | int | float | bool | None
# Function type
Predicate: TypeAlias = Callable[[int], bool]
Transformer: TypeAlias = Callable[[str], str]
# Nested types
Row: TypeAlias = dict[str, str | int | float | None]
Table: TypeAlias = list[Row]
def filter_table(table: Table, predicate: Callable[[Row], bool]) -> Table:
return [row for row in table if predicate(row)]
In Python 3.12, the type statement makes aliases cleaner:
# Python 3.12+
type JSON = dict[str, JSON] | list[JSON] | str | int | float | bool | None
type Row = dict[str, str | int | float | None]
Project: A Typed Data Pipeline
Let's build a small typed data pipeline that reads a CSV, validates the data, transforms it, and produces a report — fully annotated.
"""
pipeline.py — A typed data processing pipeline.
"""
from __future__ import annotations # allow forward references in annotations
import csv
from dataclasses import dataclass, field
from pathlib import Path
from typing import Callable, Iterator, TypeVar, NamedTuple
T = TypeVar("T")
# ── Data models ───────────────────────────────────────────────────────────────
class RawRecord(NamedTuple):
name: str
dept: str
salary: str # raw string from CSV
active: str # raw string from CSV
@dataclass(frozen=True)
class Employee:
name: str
dept: str
salary: float
active: bool
@classmethod
def from_raw(cls, raw: RawRecord) -> Employee:
return cls(
name = raw.name.strip(),
dept = raw.dept.strip(),
salary = float(raw.salary),
active = raw.active.strip().lower() in ("true", "1", "yes"),
)
@dataclass
class Report:
dept: str
headcount: int
avg_salary: float
max_salary: float
min_salary: float
# ── Pipeline stages ───────────────────────────────────────────────────────────
def read_csv(path: Path) -> Iterator[RawRecord]:
with open(path, newline="") as f:
reader = csv.DictReader(f)
for row in reader:
yield RawRecord(
name = row["name"],
dept = row["dept"],
salary = row["salary"],
active = row.get("active", "true"),
)
def parse_employees(records: Iterator[RawRecord]) -> Iterator[Employee]:
for record in records:
try:
yield Employee.from_raw(record)
except (ValueError, KeyError) as e:
print(f" Skipping invalid record {record.name!r}: {e}")
def filter_employees(
employees: Iterator[Employee],
predicate: Callable[[Employee], bool],
) -> Iterator[Employee]:
return (e for e in employees if predicate(e))
def group_by_dept(employees: Iterator[Employee]) -> dict[str, list[Employee]]:
groups: dict[str, list[Employee]] = {}
for emp in employees:
groups.setdefault(emp.dept, []).append(emp)
return groups
def make_report(dept: str, members: list[Employee]) -> Report:
salaries = [e.salary for e in members]
return Report(
dept = dept,
headcount = len(members),
avg_salary = sum(salaries) / len(salaries),
max_salary = max(salaries),
min_salary = min(salaries),
)
def print_report(reports: list[Report]) -> None:
print(f"\n{'Department':<12} {'Count':>6} {'Avg Salary':>12} {'Min':>10} {'Max':>10}")
print("-" * 55)
for r in sorted(reports, key=lambda r: r.dept):
print(
f"{r.dept:<12} {r.headcount:>6} "
f"${r.avg_salary:>10,.0f} "
f"${r.min_salary:>8,.0f} "
f"${r.max_salary:>8,.0f}"
)
# ── Main ──────────────────────────────────────────────────────────────────────
def run(path: Path, active_only: bool = True) -> list[Report]:
raw = read_csv(path)
employees = parse_employees(raw)
if active_only:
employees = filter_employees(employees, lambda e: e.active)
groups = group_by_dept(employees)
reports = [make_report(dept, members) for dept, members in groups.items()]
print_report(reports)
return reports
if __name__ == "__main__":
run(Path("employees.csv"))
Every function has annotated parameters and return types. The type checker knows that read_csv yields RawRecord, parse_employees yields Employee, group_by_dept returns dict[str, list[Employee]], and make_report returns a Report. If you wire them up wrong, mypy tells you before you run the code.
Type Hint Cheat Sheet
| Syntax | Meaning |
|---|---|
x: int |
x is an integer |
x: str | None |
x is a string or None |
x: list[int] |
list of integers |
x: dict[str, int] |
dict mapping str keys to int values |
x: tuple[int, str] |
2-tuple: int then str |
x: tuple[int, ...] |
tuple of any number of ints |
x: set[str] |
set of strings |
x: frozenset[int] |
frozenset of integers |
Optional[X] |
X | None |
Union[X, Y] |
X | Y |
Any |
any type — opt out of checking |
Callable[[int, str], bool] |
function taking int and str, returning bool |
Callable[..., int] |
function with any args, returning int |
Iterable[X] |
anything you can loop over |
Sequence[X] |
anything with len and indexing |
Mapping[K, V] |
read-only dict-like |
TypeVar("T") |
generic type placeholder |
Generic[T] |
base for generic classes |
Protocol |
structural subtyping (duck typing) |
NamedTuple |
typed named tuple |
TypedDict |
typed dictionary |
Literal["a", "b"] |
only these specific values |
Final |
cannot be reassigned |
What You Learned in This Chapter
- Type hints don't change runtime behavior — they're documentation for humans and type checkers.
- Use
->for return types and:for parameter and variable types. - Python 3.9+ supports
list[int],dict[str, int]directly; earlier versions usetyping.List,typing.Dict. - Python 3.10+ supports
X | Yinstead ofUnion[X, Y]. Optional[X]meansX | None— very common for nullable values.Callable,Iterable,Sequence,Mappingdescribe abstract interfaces, not concrete types.TypeVarenables generic functions that preserve type information.Generic[T]creates generic classes.Protocoldescribes duck-typing contracts without inheritance.NamedTuplegives typed, immutable named tuples.TypedDictgives typed dictionaries for JSON-like data.Literalrestricts values to specific constants;Finalprevents reassignment.- Run
mypyto catch type errors before they become runtime bugs.
What's Next?
Chapter 27 covers Concurrency — threading, multiprocessing, and asyncio. Python's Global Interpreter Lock (GIL) limits threads for CPU work, but they shine for I/O. asyncio lets a single thread handle thousands of concurrent I/O operations. You'll learn when to use each and how to write both threaded and async code correctly.