Python's type system is gradual and optional. We make it mandatory at module boundaries. The cost is small; the payoff is large.
from __future__ import annotations
from collections.abc import Iterable
from typing import Final, Literal, Protocol
MAX_PAGE_SIZE: Final[int] = 100
type SortDir = Literal["asc", "desc"]
class UserStore(Protocol):
def find(self, user_id: str) -> User | None: ...
def page_users[T: User](
store: UserStore, ids: Iterable[str], direction: SortDir
) -> list[T | None]:
found = (store.find(i) for i in ids)
ordered = sorted(found, key=_sort_key, reverse=direction == "desc")
return ordered[:MAX_PAGE_SIZE]Every signature is fully hinted, return type included (3.1). The module reads in modern syntax: PEP 604 User | None over Optional, builtin list[T | None], the PEP 695 type alias and inline def page_users[T: User] generic (3.2, 3.8). No Any appears at the boundary — the seam is a structural Protocol (3.4, 3.3), the bounded T carries the caller's type, SortDir is a Literal discriminator (3.6), and MAX_PAGE_SIZE is Final (3.5).
Reasoning, step by step:
- A public function's signature is its contract. Without types, the contract lives in docstrings (which rot) or in the head of whoever wrote it (which leaves).
- Public = anything that could be imported by another module. Private = leading underscore, defined inside a function, or in a
_internalmodule. - mypy strict mode (
disallow_untyped_defs) flags missing hints. Run it in CI. - Return type matters even when "obvious": it documents and lets mypy catch implementation drift.
- Anti-pattern: typing arguments but not return values, or vice versa. Both, always.
Enforcement: mypy strict (disallow_untyped_defs, disallow_incomplete_defs).
Reasoning, step by step:
- PEP 604 (Python 3.10+) allows
str | Nonein place ofOptional[str]. Shorter and more obvious. - PEP 585 (Python 3.9+) allows
list[int]in place ofList[int]. No more importing fromtyping. - PEP 695 (Python 3.12+) adds the
typestatement and inline generic syntax:def first[T](xs: list[T]) -> T: .... Use it for new code; the readability win is real. - Don't mix styles within a module. Pick the modern one.
Enforcement: Ruff UP006/UP007/UP045 (pyupgrade) rewrite legacy typing forms.
Reasoning, step by step:
Anymeans "I don't know" — and mypy will let any operation pass on a value of typeAny. It propagates: every value derived from anAnyis alsoAny.- Public function signatures should never use
Any. Replace with:object(for "any value, but I'll check"),T(generic),Protocol(structural). - Private uses of
Anyexist (FFI, dynamic loading) and are sometimes unavoidable. Comment them with# type: ignore[reason]or# Any: <why>. - mypy strict mode rejects
Anyreturns by default.
Enforcement: mypy strict (disallow_any_explicit, warn_return_any).
Reasoning, step by step:
Protocol(PEP 544) is structural typing — any class with the right methods is accepted, no inheritance needed.- ABCs (
abc.ABC,@abc.abstractmethod) require inheritance. Subclasses must declare they implement the ABC. - Pick
Protocolfor: dependency-inversion seams, "duck-typed but checked" interfaces, function parameters describing required behavior. - Pick ABC when: you need shared implementation in the base, or you genuinely want
isinstanceto enforce the relationship at runtime (rare; use@runtime_checkableon a Protocol if you do). - Pattern:
Any class with a
class Closeable(Protocol): def close(self) -> None: ... def with_resource(r: Closeable) -> None: try: ... finally: r.close()
close()method satisfiesCloseable— no inheritance.
Enforcement: review; Protocol for behavior seams, ABCs only where inheritance is needed.
Reasoning, step by step:
MAX_RETRIES: Final[int] = 3says: this value never changes. mypy enforces.- On class attributes:
class Config: timeout: Final[int] = 30prevents subclasses or instances from overriding. - Use for: configuration constants, computed-once values, class attributes that genuinely shouldn't be touched.
- Don't use for every local —
Finalis annotation overhead; reserve for the cases where the immutability matters.
Enforcement: mypy flags reassignment of a Final; review for which values earn it.
Reasoning, step by step:
Literal["asc", "desc"]is a tight type for a string flag with two valid values.- mypy enforces — passing
"random"is a type error. - Discriminator pattern for sum types:
mypy narrows on
class Approved(TypedDict): kind: Literal["approved"] receipt: Receipt class Declined(TypedDict): kind: Literal["declined"] reason: str ChargeResult = Approved | Declined
result["kind"] == "approved"automatically. - For larger sets, prefer
enum.StrEnum(3.11+) — gives you string values and a closed type.
Enforcement: mypy rejects out-of-set literals and checks discriminated-union narrowing.
Reasoning, step by step:
TypedDicttypes adictwith known string keys and per-key types. Useful for JSON request/response shapes, config dicts, kwargs bags.- Dataclasses are for objects — bound methods, identity, lifetime. TypedDict is for records.
- Rule of thumb: if it crosses the wire as JSON,
TypedDict(or a Pydantic model — see chapter 5 in kotlin-jvm for the equivalent JVM patterns; we apply a similar split here — theTypedDict-vs-dataclass split and thefrom_dict/from_jsonfactories live in chapter 06). - Inside the domain:
@dataclass(frozen=True, slots=True).
Enforcement: review; TypedDict for wire-shaped dicts, dataclasses for domain objects.
Reasoning, step by step:
def first(xs: list) -> Anyloses type information at every call. mypy can't help downstream.def first[T](xs: list[T]) -> T:(PEP 695, 3.12+) — caller gets back the type they passed in.- Constrain type variables when needed:
T: bound=Comparable,T: int | float. - Pre-3.12:
T = TypeVar("T")at module scope, thendef first(xs: list[T]) -> T. Verbose but works.
Enforcement: mypy strict (warn_return_any blocks the Any-returning shape).
Reasoning, step by step:
def with_header(self, k: str, v: str) -> Selfsays "I return my own type" — including in subclasses.- Pre-3.11: a
TypeVarbound to the class. Verbose. - With
Self, subclasses get correct types automatically:SubRequest.with_header(...)returnsSubRequest, notRequest.
Enforcement: mypy checks Self-returning methods preserve the subclass type.
Reasoning, step by step:
from __future__ import annotationsmakes all annotations strings, lazily evaluated. Fixes forward references; speeds up imports.- Adopt this in new modules. PEP 563 was deferred-permanent; PEP 649 (Python 3.13+) replaces it with a more uniform mechanism — same source-code shape.
- Side effect: annotations are strings at runtime. Libraries that introspect type hints at runtime (Pydantic, FastAPI) handle this with
typing.get_type_hints; you do the same if you must inspect annotations.
Enforcement: Ruff FA100/FA102 require the future-import where annotations need it.
Reasoning, step by step:
# type: ignoredisables mypy on that line. It's a sometimes-necessary escape valve.- Required form:
# type: ignore[error-code] # <why>. Specific error code, comment with reasoning. - Track
# type: ignorecount in CI — it shouldn't grow unbounded. - Same goes for
# noqa: always with a code and a reason.
Enforcement: Ruff PGH003/PGH004 forbid blanket # type: ignore/# noqa; track the count in CI.
Reasoning, step by step:
typing.cast(T, value)tells mypy "trust me, this is a T." It's a runtime no-op but a visible source-level claim.# type: ignoresays "stop checking" — broader, more dangerous.- When you have a value that mypy can't narrow but you know the type,
castdocuments the claim at the exact place. - Either way, the underlying issue is usually that your types are imprecise. Fix the types first; reach for
castonly when refactoring isn't feasible.
Enforcement: review; cast over # type: ignore for known-type narrowing, both kept rare.
from __future__ import annotations
from collections.abc import Iterable
from dataclasses import dataclass
from typing import Final, Literal, Protocol, Self
MAX_RETRIES: Final[int] = 3
class UserReader(Protocol):
def find(self, user_id: UserId) -> User | None: ...
@dataclass(frozen=True, slots=True)
class UserId:
raw: str
def __post_init__(self) -> None:
if not self.raw:
raise ValueError("UserId must be non-empty")
def load_users[T: User](reader: UserReader, ids: Iterable[UserId]) -> list[T]:
return [reader.find(i) for i in ids if reader.find(i) is not None] # type: ignore[return-value] # narrowing limitation- Dataclasses + Protocols + ABCs: chapter 06.
- mypy configuration: chapter 01.
- Pydantic / kwargs / TypedDict for JSON boundaries: chapter 06 (data modeling) and chapter 10 (API design).