Type Hints: Python That Documents Itself¶
What this lesson gives you
The modern, professional habit that turns dynamic Python into self-documenting, tool-checkable code — expected in any serious 2026 codebase.
Estimated time: 20 min read · Part: Values, Variables & Expressions
Learning objectives
- Annotate function signatures and variable declarations with type hints using modern Python 3.10+ syntax.
- Explain what type hints do and do not do at runtime — and why that distinction matters.
- Use the common composite types:
list[T],dict[K, V],tuple[...],X | None,Callable. - Understand TypedDict for structuring dict schemas and dataclasses as typed value containers.
- Know when to reach for mypy vs Pyright vs runtime validation (Pydantic).
Python stays dynamically typed at runtime, but since 3.5 you can annotate code with the types you intend. These type hints don't change runtime behaviour — Python ignores them when running — but tools like mypy and Pyright read them to catch bugs before you run, and your editor uses them for autocomplete and error highlighting. In a professional codebase, typed code is the norm, and showing it in an interview signals that you ship maintainable code, not one-off scripts.
The evolution of Python's type system has been rapid. Python 3.9 allowed using built-in types directly (list[int] instead of typing.List[int]). Python 3.10 introduced the X | Y union syntax (instead of typing.Union[X, Y]). Python 3.12 added type aliases and generic classes. In 2026 projects, the 3.10+ syntax is the default — clean, readable, and what code reviewers expect to see.
Basic annotation syntax¶
# Function annotations: parameters and return type
def discount(price: float, pct: float) -> float:
"""Return price after applying a percentage discount."""
return price * (1 - pct / 100)
# Variable annotations:
name: str = "Acme"
count: int = 0
active: bool = True
# Modern composite types (Python 3.9+ syntax, no imports needed):
names: list[str] = ["Acme", "Globex"]
scores: dict[str, int] = {"Ada": 95}
point: tuple[int, int] = (10, 20)
tags: set[str] = {"enterprise", "saas"}
# Union types — Python 3.10+ syntax:
def find_deal(deal_id: int) -> dict | None:
"""Return a deal dict, or None if not found."""
...
# Any value (opt out of type checking — use sparingly):
from typing import Any
def log(value: Any) -> None: ...
Hints are checked by tools, not by Python
Critical mental model: def f(x: int) does not stop someone passing a string at runtime — Python won't enforce it. The value comes from static analysis: run mypy in CI and it flags the mismatch before the code ships. Hints are a contract for humans and tools, not a runtime guard. (When you do need runtime validation — like API inputs — you reach for Pydantic, covered in Part 10.)
TypedDict and dataclasses: structure your data¶
Plain dict[str, Any] tells the type checker almost nothing useful. When a dictionary has a fixed schema — a deal record, an API response — you have two good options: TypedDict (for dict-shaped data) and dataclasses (for object-shaped data with methods). Both give the type checker full visibility into what keys/attributes exist and what types they hold.
from typing import TypedDict
from dataclasses import dataclass, field
# TypedDict: typed schema for dict-shaped data
class DealRecord(TypedDict):
id: int
name: str
mrr: float
closed: bool
def process_deal(deal: DealRecord) -> str:
return f"{deal['name']}: ${deal['mrr']:,.2f}"
# @dataclass: typed class with auto-generated __init__, __repr__, __eq__
@dataclass
class Deal:
id: int
name: str
mrr: float
closed: bool = False
tags: list[str] = field(default_factory=list) # correct mutable default!
def display(self) -> str:
return f"Deal({self.name}, ${self.mrr:,.2f})"
d = Deal(id=1, name="Acme", mrr=4999.99)
print(d) # Deal(id=1, name='Acme', mrr=4999.99, closed=False, tags=[])
print(d.display()) # Deal(Acme, $4,999.99)
Deal(id=1, name='Acme', mrr=4999.99, closed=False, tags=[]) Deal(Acme, $4,999.99)
Callable, Final, Literal, and the typing toolbox¶
from typing import Callable, Final, Literal, TypeVar
from collections.abc import Sequence, Iterator
# Callable: annotate functions-as-arguments
def apply_transform(data: list[float], fn: Callable[[float], float]) -> list[float]:
return [fn(x) for x in data]
# Final: a name that should never be rebound
MAX_RETRIES: Final[int] = 3
# Literal: constrain to specific values
def set_status(status: Literal["open", "closed", "pending"]) -> None: ...
# TypeVar: generic functions
T = TypeVar("T")
def first(items: Sequence[T]) -> T:
return items[0]
# Sequence: read-only, accepts list, tuple, str — prefer over list[T] when
# you only need to iterate (more flexible for callers)
def total_mrr(deals: Sequence[Deal]) -> float:
return sum(d.mrr for d in deals)
TYPE CHECKING PIPELINE IN 2026
═══════════════════════════════════════════════════════
Source code with hints (.py)
│
├──► mypy / Pyright ──► Static analysis
│ (pre-commit hook, CI) │
│ ├── Type errors flagged BEFORE run
│ └── Editor shows squiggles live
│
├──► Python runtime ──► Hints IGNORED, no enforcement
│
└──► Pydantic / attrs ──► Runtime validation
(for API inputs, (DOES enforce at runtime)
user data, configs)
| Hint | Means | Notes |
|---|---|---|
int, str, float, bool | Built-in scalar types | Use directly; no import needed |
list[int] | A list of integers | 3.9+ syntax; old: List[int] from typing |
dict[str, float] | String keys, float values | 3.9+ syntax; old: Dict[str, float] |
tuple[int, str] | Fixed-length tuple | tuple[int, ...] = variable-length int tuple |
set[str] | A set of strings | 3.9+ syntax |
str \| None | Optional string (may be None) | 3.10+ syntax; old: Optional[str] |
int \| str | Union: int or str | 3.10+ syntax; old: Union[int, str] |
Callable[[int], str] | Function taking int, returning str | From typing or collections.abc |
Sequence[T] | Any readable sequence | Broader than list[T]; accept list/tuple/str |
Final[int] | Value should not be re-bound | Constant declaration |
Literal["a","b"] | Must be one of these exact values | Great for status fields, modes |
TypedDict | Dict with a fixed typed schema | For JSON-shaped data structures |
Any | Opt out of type checking | Use sparingly; escape hatch only |
| Tool | Role | When to use |
|---|---|---|
| mypy | Static type checker (original) | Most projects; well-established |
| Pyright / Pylance | Microsoft's checker; powers VS Code | VS Code users; faster, stricter |
| Pydantic v2 | Runtime validation via type hints | API inputs, configs, data loading |
| beartype | Lightweight runtime checker | When you want runtime enforcement without Pydantic's weight |
| Ruff | Linter + formatter | All projects; replaces flake8/black |
Consulting lens
When you hand a customer's engineers a script, type hints + a docstring are the cheapest documentation you'll ever write. They make the script self-explaining, catch integration mismatches early, and make you look like someone who ships maintainable code rather than a throwaway hack. Typed function signatures also make whiteboard discussions precise: the signature is the interface. When a customer asks "what does this function take?", pointing to the signature gives an immediate, unambiguous answer without opening the body.
WHY type hints if Python doesn't enforce them Three compounding benefits: (1) Documentation — the signature tells you what a function expects without reading the body. (2) Editor support — autocomplete, parameter hints, and red squiggles on type mismatches. (3) Static checking — mypy/Pyright catch "passed a str where int expected" before the code ships. None of these require runtime enforcement to be valuable, and together they prevent a large class of bugs in large codebases.
HOW to adopt type hints incrementally You don't have to annotate everything at once. Start by annotating all function signatures (parameters and returns) — that gives you the best documentation-to-effort ratio. Then annotate module-level variables. Run mypy with --ignore-missing-imports at first to avoid noise from untyped third-party libraries. Add mypy to your pre-commit hooks and CI to prevent regressions. Progressively tighten the config over time.
WHERE runtime validation is still needed At system boundaries — API endpoints receiving JSON, config files loaded from disk, CSV data ingested from unknown sources. These inputs are not controlled by your code and can contain anything. Type hints tell you what you expect; Pydantic (or similar) validates that the actual data conforms at runtime and produces a clear error when it doesn't. Static hints + runtime validation are complementary, not redundant.
Knowledge check
Interview"If I annotate def f(x: int) and then call f('hello'), what happens at runtime?"
- Python raises a
TypeErrorimmediately because the hint is enforced. - Nothing special at runtime — Python ignores hints; the string is passed through. A static checker like mypy would flag it beforehand, but the program itself doesn't enforce the hint.
- The string is automatically converted to an integer.
- The function silently returns
None.
Answer
Nothing special at runtime — Python ignores hints; the string is passed through. A static checker like mypy would flag it beforehand, but the program itself doesn't enforce the hint.
Type hints are erased at runtime — Python neither checks nor converts. Their value is realised by external tools (mypy/Pyright) and editors. For genuine runtime enforcement you validate explicitly (e.g. Pydantic models). Confusing "annotated" with "enforced" is the trap here.
Knowledge check
Concept checkWhat is the difference between list[str] and Sequence[str] as a function parameter type, and when should you prefer each?
- They are identical; the names are interchangeable aliases.
list[str]only accepts lists;Sequence[str]accepts any indexable sequence — list, tuple, string. PreferSequence[str]when you only need to iterate/index, since it makes your function more flexible for callers without extra cost.Sequence[str]only accepts tuples.list[str]is the more general type.
Answer
list[str] only accepts lists; Sequence[str] accepts any indexable sequence — list, tuple, string. Prefer Sequence[str] when you only need to iterate/index, since it makes your function more flexible for callers without extra cost.
Prefer the broadest type that correctly describes what your function needs. If you only iterate, Sequence[str] lets callers pass tuples or any sequence type without needing to convert to a list first. If you need mutation (append/remove), then list[str] is correct and necessary.
Knowledge check
AppliedYou're writing an integration that receives webhook payloads as JSON dicts. Which approach gives you both type safety and runtime validation?
- Annotate the function with
dict[str, Any]— that's enough. - Use type hints only — they enforce types at runtime anyway.
- Define a Pydantic model for the payload schema. It gives type-checker visibility via hints AND validates the actual incoming data at runtime, raising clear errors for missing or mistyped fields.
- Write manual
isinstancechecks for every field — that's the only reliable way.
Answer
Define a Pydantic model for the payload schema. It gives type-checker visibility via hints AND validates the actual incoming data at runtime, raising clear errors for missing or mistyped fields.
Type hints alone don't validate runtime data. Pydantic models do both: they define a schema (visible to the type checker) and parse/validate incoming dicts at runtime, coercing where possible and raising ValidationError with specific field messages when data is malformed. This is the standard pattern for API inputs and webhook handlers in production Python.
Exercise
Define a TypedDict called WebhookPayload with fields: event_type: str, account_id: int, mrr_change: float, and metadata: dict[str, str]. Then write a fully typed function handle_webhook(payload: WebhookPayload) -> str that returns a human-readable summary string. Finally, write a second version using a Pydantic BaseModel that also validates that mrr_change is non-negative (use a field validator). Show both versions running with a sample payload.
Show solution
# Version 1: TypedDict (type-checker only, no runtime validation)
from typing import TypedDict
class WebhookPayload(TypedDict):
event_type: str
account_id: int
mrr_change: float
metadata: dict[str, str]
def handle_webhook(payload: WebhookPayload) -> str:
return (
f"Event '{payload['event_type']}' for account {payload['account_id']}: "
f"MRR change ${payload['mrr_change']:+,.2f}"
)
sample: WebhookPayload = {
"event_type": "upgrade",
"account_id": 42,
"mrr_change": 500.0,
"metadata": {"plan": "enterprise"},
}
print(handle_webhook(sample))
# Version 2: Pydantic (type-checker + runtime validation)
from pydantic import BaseModel, field_validator
class WebhookPayloadModel(BaseModel):
event_type: str
account_id: int
mrr_change: float
metadata: dict[str, str]
@field_validator("mrr_change")
@classmethod
def mrr_must_be_non_negative(cls, v: float) -> float:
if v < 0:
raise ValueError("mrr_change must be >= 0")
return v
def handle_webhook_v2(payload: WebhookPayloadModel) -> str:
return (
f"Event '{payload.event_type}' for account {payload.account_id}: "
f"MRR change ${payload.mrr_change:+,.2f}"
)
# Valid payload:
p = WebhookPayloadModel(event_type="upgrade", account_id=42,
mrr_change=500.0, metadata={"plan": "enterprise"})
print(handle_webhook_v2(p))
# Invalid payload (raises ValidationError):
try:
bad = WebhookPayloadModel(event_type="downgrade", account_id=42,
mrr_change=-100.0, metadata={})
except Exception as e:
print(f"Validation error: {e}")
Key takeaways
- Type hints annotate intended types; Python ignores them at runtime — they're for tools and humans.
- Static checkers (mypy, Pyright) and editors use hints to catch bugs early and power autocomplete.
- Modern syntax (3.10+):
list[int],dict[str, float],X | Nonefor optionals,X | Yfor unions. - TypedDict types dict schemas; @dataclass creates typed data classes with auto-generated boilerplate.
- For runtime validation (API inputs, external data), add Pydantic — hints alone won't catch bad data at runtime.
- Hints + docstrings are the cheapest, highest-leverage documentation — standard in serious 2026 code.