AnyLearn
All lessons
Programmingadvanced

Advanced Python typing for backend

Python's type system has two audiences — static checkers and runtime frameworks. Generics, Protocol, TypedDict, ParamSpec, type narrowing, and runtime introspection, framed as the spec that Pydantic, FastAPI, and SQLAlchemy actually execute.

Not signed in — your progress and quiz score won't be saved.
Lesson progress1 / 8

Two audiences for a single annotation

Python is dynamically typed. x: int = "five" runs without complaint and x holds the string. So why annotate?

Two reasons, and they live in different layers of the toolchain:

  1. Static checkers (mypy, pyright, Pylance) read annotations from source and prove invariants before code runs. A misuse fails the build, not production.
  2. Runtime libraries read annotations through typing.get_type_hints() and use them to drive behavior. Pydantic compiles a validator from your model. FastAPI builds the dependency graph and OpenAPI schema. SQLAlchemy 2.0 maps Mapped[int] to a column. attrs generates __init__.

The same annotation has two audiences. A list[User] tells the checker what indexing returns; it tells Pydantic to deserialize each element as a User and reject anything else. In a backend codebase, knowing which layer reads which annotation is the difference between types as docs and types as the spec the framework executes.

Full lesson text

All 8 steps on one page — for reading, reference, and search.

Show

1. Two audiences for a single annotation

Python is dynamically typed. x: int = "five" runs without complaint and x holds the string. So why annotate?

Two reasons, and they live in different layers of the toolchain:

  1. Static checkers (mypy, pyright, Pylance) read annotations from source and prove invariants before code runs. A misuse fails the build, not production.
  2. Runtime libraries read annotations through typing.get_type_hints() and use them to drive behavior. Pydantic compiles a validator from your model. FastAPI builds the dependency graph and OpenAPI schema. SQLAlchemy 2.0 maps Mapped[int] to a column. attrs generates __init__.

The same annotation has two audiences. A list[User] tells the checker what indexing returns; it tells Pydantic to deserialize each element as a User and reject anything else. In a backend codebase, knowing which layer reads which annotation is the difference between types as docs and types as the spec the framework executes.

2. Generics — TypeVar and the PEP 695 syntax

A TypeVar is a placeholder: whatever type comes in, the same type goes out.

from typing import TypeVar

T = TypeVar("T")

def first(items: list[T]) -> T:
    return items[0]

x: int = first([1, 2, 3])      # checker infers T = int
y: str = first(["a", "b"])      # checker infers T = str

Python 3.12 ships PEP 695 — a cleaner syntax that scopes the type variable to the declaration:

def first[T](items: list[T]) -> T:
    return items[0]

class Stack[T]:
    def __init__(self) -> None:
        self._items: list[T] = []
    def push(self, x: T) -> None:
        self._items.append(x)
    def pop(self) -> T:
        return self._items.pop()

No module-level T = TypeVar(...) boilerplate. Bounded and constrained variables exist too: def f[T: (int, str)](x: T) restricts T to those two types; def f[T: Number](x: T) lets you call Number methods on T.

3. Protocol — structural typing without inheritance

Nominal typing asks: does this object inherit from the interface? Python's Protocol (PEP 544) asks the duck-typing question: does this object have the right shape?

from typing import Protocol

class SupportsClose(Protocol):
    def close(self) -> None: ...

def cleanup(resource: SupportsClose) -> None:
    resource.close()

class FileLike:
    def close(self) -> None: ...

class Socket:
    def close(self) -> None: ...

cleanup(FileLike())   # type-checks
cleanup(Socket())     # type-checks — no shared base class

The check is structural: any class with a matching close(self) -> None is acceptable. This is how the stdlib types Iterable, Hashable, and SupportsInt work. Decorating with @runtime_checkable lets isinstance(x, SupportsClose) work at runtime — but it only checks attribute presence, not signature, so use it sparingly.

Protocols are how you accept anything with these methods without forcing every caller into your class hierarchy. For library APIs in particular, prefer Protocol over ABC.

4. TypedDict — typed shapes for plain dicts

When the data is genuinely a dict (a JSON payload, a Redis hash, a row from psycopg), TypedDict gives you field-level static types without converting to a class:

from typing import TypedDict, NotRequired

class UserRow(TypedDict):
    id: int
    email: str
    nickname: NotRequired[str]    # PEP 655: this key may be absent

def display_name(u: UserRow) -> str:
    return u.get("nickname") or u["email"]

TypedDict does not validate at runtime — it's a static-only hint. You still need Pydantic or manual validation for untrusted input. What it buys you is checker-enforced safety on internal dict-shaped data: u["nicknaem"] fails the build, u["id"] + 1 type-checks as int, and the runtime cost is zero — it is a dict.

The rule of thumb: TypedDict for I want a dict with shape safety; dataclass for value object with methods; Pydantic for runtime validation and serialization. Picking the wrong one either over-pays at runtime or under-protects against bad data.

5. ParamSpec — preserving signatures through decorators

Decorators that wrap a function are usually typed wrong: the wrapper falls back to Callable[..., R] and the caller loses every parameter type. ParamSpec (PEP 612) preserves the wrapped function's full signature:

from typing import ParamSpec, TypeVar, Callable
import functools, time

P = ParamSpec("P")
R = TypeVar("R")

def timed(fn: Callable[P, R]) -> Callable[P, R]:
    @functools.wraps(fn)
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
        t0 = time.monotonic()
        try:
            return fn(*args, **kwargs)
        finally:
            print(f"{fn.__name__} took {time.monotonic() - t0:.3f}s")
    return wrapper

@timed
def query(user_id: int, *, include_posts: bool = False) -> dict:
    ...

query(42, include_posts=True)   # checker sees the original signature
query("oops")                    # checker rejects the wrong-typed call

PEP 695 syntax in 3.12: def timed[**P, R](fn: Callable[P, R]) -> Callable[P, R]. Without ParamSpec every retry wrapper, caching layer, and FastAPI route decorator silently strips the handler's contract — and bugs slip through type-clean diffs.

6. Type narrowing — how the checker follows control flow

The checker tracks union types and narrows them inside conditional branches. Most narrowing is automatic; the hard cases get user-defined predicates.

from typing import TypeIs

def handle(x: int | None) -> int:
    if x is None:
        return 0
    return x + 1   # narrowed to int

def must_have_user(u: User | None) -> User:
    assert u is not None
    return u   # narrowed to User

def is_str_list(val: list[object]) -> TypeIs[list[str]]:
    return all(isinstance(x, str) for x in val)

def join(items: list[object]) -> str:
    if is_str_list(items):
        return ", ".join(items)   # narrowed to list[str]
    return ""

Narrowing rules:

  • isinstance(x, T) → narrows to T inside the True branch.
  • x is None / x is not None → drops None from the union.
  • assert <cond> → narrows on the asserted condition.
  • User-defined TypeGuard (3.10) narrows only the True branch. TypeIs (3.13) is the strict version that narrows both branches symmetrically.

Reach for TypeIs before cast. cast silences the checker; TypeIs keeps the proof — you just wrote the predicate yourself.

7. How frameworks read annotations at runtime

The runtime side of the same annotation. typing.get_type_hints(obj) resolves a class or function's annotations — including forward references and ClassVars — into actual type objects. Frameworks pivot on it:

import typing, inspect

def handler(user_id: int, q: str | None = None) -> dict:
    return {"id": user_id, "q": q}

print(typing.get_type_hints(handler))
# {'user_id': <class 'int'>, 'q': str | None, 'return': <class 'dict'>}

print(inspect.signature(handler))
# (user_id: int, q: str | None = None) -> dict

FastAPI walks inspect.signature of every route, maps each parameter to a source (path / query / body / Depends), and uses typing.get_type_hints to build the Pydantic validator. Pydantic v2 reads model fields and compiles them into a Rust-implemented validator (pydantic-core). SQLAlchemy 2.0's Mapped[int] is read at class-creation time to emit columns. attrs and dataclasses walk the annotation dict to generate __init__.

The implication: a wrong annotation is a wrong validator. You are writing the runtime spec, whether you meant to or not.

8. Gotchas — the runtime/static gap

The traps that bite real codebases:

  • from __future__ import annotations (PEP 563) makes every annotation a string at runtime. Tools that call typing.get_type_hints() still resolve them, but eager runtime introspection (some metaclass tricks, older libraries) sees strings. Pydantic v2 handles it; v1 does not.
  • Optional[T] vs T | None are the same statically. Prefer the pipe syntax (3.10+) — shorter, and Optional looks like it implies a default value, which it does not.
  • Mutable default arguments still bite. def f(x: list[int] = []): shares one list across every call. The type system does not catch it; runtime does, badly.
  • Type parameters are erased at runtime. list[int]() is identical to list(). Branching on a runtime "type parameter" reads metadata that does not exist; use __class_getitem__ or Generic introspection only if you understand what you'll get back.
  • reveal_type(x) is a checker-only affordance — at runtime it raises NameError. Useful while debugging inference; delete before commit.

The through-line: the static and runtime layers do not always agree. Knowing which one you're reasoning about is what separates the people who debug Pydantic stack traces from the people who don't have them.

Check your understanding

The lesson ends with a 5-question quiz. Take it in the player above to see your score.

  1. Pydantic v2 builds a validator for a model class from its field annotations. At what point does it read those annotations?
    • Statically, by parsing the source file ahead of time
    • At runtime, via `typing.get_type_hints()` on the class
    • It does not read annotations; it relies on explicit `Field(...)` calls
    • At import time, but only when running under mypy
  2. You want a function to accept any object with a `.close() -> None` method, without forcing callers to inherit from a base class. Which type tool fits?
    • An abstract base class (`ABC`) with `close` as `abstractmethod`
    • A `Protocol` with `def close(self) -> None: ...`
    • A `TypedDict` with a `close` field of type `Callable[[], None]`
    • A `TypeVar` bound to a class named `Closable`
  3. Without `ParamSpec`, a decorator typed as `def deco(fn: Callable[..., R]) -> Callable[..., R]` produces what effect at the call site?
    • The wrapped function's parameter types are preserved exactly
    • The wrapped function's parameter types are lost — the checker accepts any arguments
    • The decorator stops type-checking entirely and errors at import
    • Pyright shows the parameters but mypy does not
  4. What is an instance of a `TypedDict` at runtime?
    • A custom class with validation methods generated by the metaclass
    • A plain `dict` — no runtime validation, no special methods
    • A frozen dataclass with the declared fields
    • A Pydantic model with `model_config['frozen'] = True`
  5. `TypeGuard[T]` and `TypeIs[T]` both let a user-defined predicate narrow a value's type. What is the difference?
    • `TypeGuard` works at runtime; `TypeIs` works only statically
    • `TypeGuard` narrows only the True branch; `TypeIs` narrows both branches symmetrically
    • `TypeIs` is the legacy name; `TypeGuard` replaced it in 3.13
    • `TypeGuard` requires the predicate to be `@runtime_checkable`; `TypeIs` does not

Related lessons