open atlas
↑ Back to track
Python for JS/TS developers PY · 04 · 01

Type hints as a system: unions, TypedDict, Literal, and typing the boundaries first

Python hints are erased at runtime — only a checker enforces them. X | Y unions, collection generics, TypedDict, Literal and Annotated turn dicts and string flags into contracts. In legacy code, type the boundaries first: a checked hint is documentation that cannot go stale.

PY Middle ◷ 17 min
Level
FoundationsJuniorMiddleSenior

The signature said def load_user(user_id: str) -> User: and it had said that for three years. A new engineer, fresh off a TypeScript codebase, read the hint, trusted it, and chained load_user(uid).subscription.plan in the billing job. At 02:40 the pager fired: AttributeError: 'NoneType' object has no attribute 'subscription', four thousand invoices stuck. The function had always returned None for soft-deleted accounts — every veteran on the team “just knew” — but the annotation never admitted it, because nothing ever made it admit it. The team had adopted type hints two years earlier “as documentation.” They never wired up a checker, so the documentation drifted exactly the way comments drift: silently, one refactor at a time. The postmortem fix was eleven characters — -> User | None — plus one CI job that would have rejected the lie the day it was introduced. Hints that nobody checks are comments with better syntax.

What the runtime actually does with a hint: nothing

This distinction is why the Hook’s bug stayed hidden for three years. When you understand what the runtime actually does — and doesn’t do — with an annotation, the “type the boundaries first” strategy stops being advice and starts being obvious.

CPython evaluates annotations and stores them in the function’s __annotations__ dict — and that is the whole story. def f(x: int) -> str will happily accept a list and return None; there is no check, no coercion, no warning, and essentially no per-call cost. This is a deliberate design (PEP 484 calls the types “hints” on purpose): the language stays dynamic, and enforcement is delegated to external tools — mypy, pyright, your IDE. The corollary cuts both ways. Nothing breaks if your hints are wrong, which is why unchecked hints rot into lies like the Hook’s -> User. But because annotations are introspectable data, runtime frameworks deliberately read them: pydantic builds validators from them, FastAPI derives request parsing and OpenAPI schemas, dataclasses derive fields. One syntax, two consumers — static checkers that trust hints and runtime libraries that execute them — and confusing the two is the classic beginner trap: writing x: int does not validate x any more than a comment would.

The vocabulary: unions, generics, TypedDict, Literal, Annotated

Before diving into syntax: ask yourself how many anonymous dicts your service passes around without names or enforced shapes. Each one is a contract that lives only in someone’s head. The vocabulary below exists to write those contracts down so the checker can enforce them.

Modern hint syntax (Python 3.10+) is compact enough to use everywhere. X | Y (PEP 604) replaces Optional[X] and Union[X, Y]; built-in collections are generic directly — list[int], dict[str, Decimal] — no typing.List imports. The workhorses for real codebases are the shape types:

from typing import Annotated, Literal, TypedDict

class Charge(TypedDict):
    amount_cents: int
    currency: Literal["usd", "eur"]   # only these two strings type-check
    customer_id: str

class ChargeWithMeta(Charge, total=False):
    idempotency_key: str              # optional key: may be absent

UserId = Annotated[str, "uuid4 from users.id"]  # extra metadata, same type

def settle(charge: Charge, retries: list[int] | None = None) -> bool:
    if charge["currency"] == "eur":   # checker knows the literal set
        ...
    return True

TypedDict is the most underrated tool in a legacy codebase: it gives a name and a checked shape to the anonymous dicts that flow through every Python service, without changing a single runtime byte — Charge is still a plain dict at runtime, so no caller breaks. Literal turns stringly-typed mode flags into closed sets, so settle(..., currency="usdd") becomes a build failure instead of a 3 a.m. KeyError in a downstream ledger. Annotated attaches metadata (units, validation rules) that tools like pydantic and FastAPI consume while checkers see the underlying type. The tradeoff to state honestly: TypedDict checks keys and value types but not extra keys by default, and it cannot stop someone constructing the dict by hand with a typo at an unchecked call site — coverage is only as good as how much of the call graph the checker sees.

Quiz

A function is annotated def f(x: int) -> str. At runtime a caller passes a list. What happens on the call itself?

Gradual typing a legacy codebase: boundaries first

A 100k-line untyped service cannot be typed in one heroic sprint, and trying produces a churn-everything PR nobody can review. The strategy that works is gradual typing from the boundaries inward. Type the edges first: HTTP handlers, DB access functions, queue consumers, the public functions of each package — the places where data enters and leaves, because that is where a wrong shape does the most damage and where a TypedDict or dataclass documents the contract other teams depend on. Internal helpers can stay untyped initially; a checker treats unannotated functions as Any and skips them, so the typed surface grows without false alarms. Then ratchet: enforce per-module strictness in config (mypy’s per-module sections, pyright’s execution environments) so newly typed modules can never regress, and require annotations on all new code. Teams that do this report the payoff quickly — the checker starts catching real bugs (forgotten None, swapped argument order, dict typos) within the first few boundary modules, long before coverage is impressive. The anti-pattern is sprinkling hints on internals while the I/O edge still passes raw dicts: you get annotation noise with none of the contract value.

Quiz

You have one week to introduce typing into a 100k-line untyped Django service. Where do hints buy the most safety per hour spent?

Recall before you leave
  1. 01
    What does the Python runtime do with type hints, and what are the two kinds of consumers that give hints value?
  2. 02
    You inherit a large untyped codebase. Describe the gradual-typing strategy and why TypedDict is central to it.
Recap

Python’s typing is a contract layer the runtime deliberately ignores: annotations are evaluated into __annotations__ and never enforced, so def f(x: int) -> str accepts a list without a murmur. Enforcement belongs to consumers — static checkers like mypy and pyright that verify before run, and runtime libraries like pydantic and FastAPI that introspect hints to build validators and schemas. The modern vocabulary makes contracts cheap to write: X | Y unions from PEP 604, generic built-ins like dict[str, Decimal], TypedDict to give anonymous dicts a named, checked shape while staying plain dicts at runtime, Literal to close stringly-typed flag sets, and Annotated to carry metadata for runtime tools. The failure mode is the unchecked hint: documentation that drifts silently, like a -> User that returned None for three years until a trusting reader shipped an AttributeError to production. The strategy for legacy code is gradual typing from the boundaries inward — handlers, DB accessors, consumers, public APIs first, because that is where shape bugs hurt — relying on checkers treating unannotated code as Any, then ratcheting per-module strictness so typed modules never regress. Hints earn their keep only when something checks them; wired to a checker, they are the one form of documentation that fails the build instead of going stale. Now when you see an unannotated boundary function in a PR, you know exactly what to do: annotate it with its real shape, wire a checker to CI, and make it impossible to lie again.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 6 done
Connected lessons

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.