Python typing
Correct how Python type annotations get written by default. Apply this when adding, fixing, or reviewing types, or when adopting a checker. Target pyright and mypy.
Types are an asset: once written, pydantic, Hypothesis (from_type), Typer,
FastAPI, and beartype read the same annotations for free, so precision pays
compounding returns. Do not treat annotations as decoration.
Core principle
Fix looseness at the source, not at each use. When a value is Any or vaguely
typed, give it a precise type at its definition (a pydantic model, a TypedDict,
a type parameter), then let that type propagate. Do not cast at each consumer.
One typed source makes every downstream use type-check for free.
Procedure
When adding types to a codebase or driving it toward strict, work in this order.
- Checker, stubs, version. Configure pyright or mypy in strict mode. First
detect the project's Python floor (
requires-python) and pick syntax to match: 3.12+ allowsdef f[T], 3.13+ addsTypeIs; below that useTypeVar. Bump the floor only if the project allows it, do not assume it. Install missing stubs (see S3). Until imports stop resolving toAny, later work types against holes. Readreferences/tooling.mdandreferences/codegen-stubs.md. - Type the boundaries. Parse untyped input through a pydantic model at every
edge (S1). Generate models from a schema or sample where one exists (S4). Read
references/boundary.md. - Autoannotate, then ratchet. Generate mechanical annotations, then track two
counts that only fall: checker errors and type holes (
cast, explicitAny, coercions). mypy skips unannotated bodies by default; setcheck_untyped_defsso the ratchet is honest. Readreferences/tooling.md. - Grind, or be born strict. Type the shared boundary reader first; its consumers become checkable for free. Write new modules strict from the start.
The corrections
Each is a default the model produces and the fix. The after snippets are
verified clean under pyright + mypy + basedpyright.
S1. Parse through the model at boundaries
The default reads resp.json(), json.loads(...), or a DB row as Any and
indexes into it. Any hides the shape and the checker stays blind.
# before: data is Any; data["name"] is unchecked
data = resp.json()
return data["name"]
# after: a typed object flows inward
user = User.model_validate(resp.json())
return user.name # str
Set model_config = ConfigDict(extra="forbid") so renamed or misspelled fields
raise instead of being silently dropped (the default is extra="ignore"). In
pydantic v2, Optional[T] is a required field that accepts None; write = None
for a field that is actually optional. Read references/boundary.md.
S2. Let the type flow with a type parameter
The default widens to Any or object and re-narrows with isinstance/cast.
Use a type parameter so the caller's type carries through.
def first(items: list[Any]) -> Any: ... # before: element type lost
def first[T](items: list[T]) -> T: ... # after: type flows, first([1]) is int
This is a different case from S6: reach for a generic only when the code is
genuinely generic. Read references/authoring.md.
S3. Install stubs before typing around a hole
When an import resolves to Any, install its stubs first. Some libraries ship
types inline (a py.typed marker), some need a types-* package, and for a
library that has types but does not expose them, generate a local stub with
stubgen. Do not annotate around a phantom Any. Distinguish this from a typed
library whose method legitimately returns Any (duckdb fetchone(),
json.loads): that is not a stub problem, so narrow or validate the value
instead of hunting for a stub package. Read references/codegen-stubs.md.
S4. Generate types instead of hand-writing them
Hand-transcribed models drift and drop fields. Generate from the source of truth.
datamodel-codegen --input sample.json --input-file-type json \
--output-model-type pydantic_v2.BaseModel
Use stubgen for stubs. Hand-write only what carries intent. Read
references/codegen-stubs.md.
S5. Make illegal states unrepresentable
The default types a closed set as str or int. Use Literal or Enum for
closed sets, unions for either/or, discriminated unions for multi-shape payloads.
Pair with assert_never so an added variant fails the check.
Status = Literal["ok", "error"]
def describe(s: Status) -> str:
match s:
case "ok": return "all good"
case "error": return "failed"
case _: assert_never(s) # a new member breaks this
assert_never enforces exhaustiveness; assert False only suppresses the error.
Read references/authoring.md.
S6. Name the shape instead of dict/object/Any
When a value has a known shape, name it. The tell is the access pattern: if you
read it by a literal key (x["name"], x.get("name")), the keys are known at
authoring time, so name it; only variable-key reads (x[k]) justify dict or
Mapping. A value read by both a literal and a variable key is still partially
known: name the known keys and keep an explicit open tail for the rest. Banning
Any alone just pushes the default down the ladder to object then
dict[str, object]; the literal-key test catches that retreat.
def make_user() -> dict: ... # before: shape and value types lost
class User(TypedDict): # after
name: str
age: int
def make_user() -> User: ...
Use TypedDict for internal known-shape dicts (static, no runtime cost); use
pydantic at boundaries where you also validate at runtime. Read
references/authoring.md.
Do not launder
Making a strict error vanish is not fixing it. cast(...), int()/float()/
bool() around dict.get(...), and bare # type: ignore buy no safety, and
int()/float() also truncate or produce NaN silently. Strict mode permits
explicit Any, so a module can pass strict while laundering. Narrow with
isinstance, or validate with a model, and scope every ignore to a specific
error code. Read references/tooling.md.
Reference files
references/boundary.md: parsing input, pydantic config, fetch/JSON, DB rows.references/authoring.md: generics, sum types, precise types, narrowing, decorators.references/tooling.md: checker config, strict flags, the ratchet, checker differences.references/codegen-stubs.md: installing stubs and generating types.