Skip to content

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

hints_basic.py
# 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.

typed_structures.py
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

advanced_hints.py
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 TypeError immediately 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. Prefer Sequence[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 isinstance checks 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 | None for optionals, X | Y for 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.