| name | precise-type-modeling |
| description | Use when authoring or converting Python type annotations โ defining a class / TypedDict / pydantic model / function signature, typing arguments or state, deciding between optional (None / NotRequired) fields and a union, or about to reach for Any, dict[str, Any], cast(), or a type-ignore comment. The minimum modeling rules for typed Python under pyright. |
Precise Type Modeling
Core principle
A type must model reality exactly: every argument and every state, with no looser shape than the truth. Any, bare object, dict[str, Any], and cast() are where models go to die โ they each say "I gave up modeling here." When you add types to Python, the goal is not "pyright passes" but "impossible states are unrepresentable."
The rules (minimum bar)
- No escape-hatch types for data you control. Ban
Any. Ban dict[str, Any] / dict[str, object] for "arbitrary" or "pass-through" data โ "arbitrary" almost always means "I didn't enumerate it yet." Enumerate the fields as a TypedDict, frozen dataclass, or pydantic model, or model the variants as a union of concrete shapes.
- Untyped data only at a true external boundary, and only in transit. Network/JSON/
os.environ may arrive untyped โ immediately parse it into a concrete type (pydantic .model_validate / TypeAdapter.validate_json, or a TypeGuard). Any/object must never be a field type, a return type, or a resting place; it is a doorway, not a room.
- Model every state as a tagged union โ the union IS the state-transition diagram. A value that can be in N states = N classes, each with a
Literal discriminant tag (kind/status/outcome) carrying only that state's data, combined as X | Y | Z. Reading success data off a failure becomes a pyright error, and match + assert_never flags missing states.
- Optional fields are a smell โ usually a collapsed union. If field A is set only when field B is, those are two variants, not two
| None (or NotRequired) fields. Many optionals on one class = you merged several states into one shape and pushed the distinction onto the reader. Reserve | None for a field that is genuinely, independently absent (e.g. description), not for state.
- Explicit annotations over
cast(). cast() and # type: ignore silence the checker and can lie; an explicit variable or return annotation makes pyright check the value against the type. Annotate and let pyright verify, never cast. Use cast() only for narrowing the checker genuinely cannot express โ and prefer a TypeGuard even then.
Before / after
Optionals hiding a state machine โ tagged union (rules 3, 4)
@dataclass
class AuthResult:
ok: bool
user: User | None = None
session: Session | None = None
code: str | None = None
status: int | None = None
challenge_id: str | None = None
methods: list[AuthMethod] | None = None
class Success(BaseModel):
outcome: Literal["success"] = "success"
user: User
session: Session
class Failure(BaseModel):
outcome: Literal["failure"] = "failure"
code: str
status: int
class MfaRequired(BaseModel):
outcome: Literal["mfa_required"] = "mfa_required"
challenge_id: str
methods: list[AuthMethod]
type AuthResult = Success | Failure | MfaRequired
def summarize(r: AuthResult) -> str:
match r:
case Success(user=user):
return f"signed in as {user.name}"
case Failure(code=code, status=status):
return f"failed ({code}, {status})"
case MfaRequired(methods=methods):
return f"mfa: {', '.join(methods)}"
case _:
assert_never(r)
"Arbitrary pass-through" โ enumerated concrete type (rules 1, 2)
@dataclass
class AuthOptions:
client_ip: str | None
require_database: bool
flags: dict[str, Any]
@dataclass(frozen=True)
class FeatureFlags:
beta_passkey_flow: bool
rollout_bucket: int
@dataclass(frozen=True)
class AuthOptions:
client_ip: str | None
require_database: bool
flags: FeatureFlags
At a true boundary, parse straight into the model โ never store raw JSON:
result = TypeAdapter[AuthResult](AuthResult).validate_json(response.text)
Annotate-and-check, not cast (rule 5)
opts = cast(AuthOptions, {"client_ip": "203.0.113.7"})
opts = AuthOptions(**raw)
opts: AuthOptions = AuthOptions(
client_ip="203.0.113.7", require_database=True, flags=default_flags
)
Quick reference
| You're about to write | Do instead |
|---|
Any | model it; if truly external, pydantic .model_validate at the boundary |
dict[str, Any] / dict[str, object] | enumerate fields: TypedDict / frozen dataclass / pydantic model |
Any / object as a field or return type | parse at the boundary into a concrete type first |
class with many | None / NotRequired fields | split into a Literal-tagged union by state |
bool flags + optional payload | one class per state, payload required inside it |
cast(T, x) | explicit annotation pyright checks; or a TypeGuard |
# type: ignore to "make it work" | fix the model; ignore is never the answer |
Common rationalizations
| Excuse | Reality |
|---|
"The flags are arbitrary, I need dict[str, Any]" | "Arbitrary" = unenumerated. List them; add a field when a flag is born. |
| "Optionals are simpler than a union" | They push every is not None check onto every reader, forever. The union checks once. |
"cast is fine, I know the shape" | Then an annotation proves it for free. cast only matters when you might be wrong โ exactly when it's dangerous. |
"object is the safe version of Any" | Only if narrowed immediately. As a resting type it's Any with extra steps. |
| "I'll model it properly later" | The optional-soup class is the thing you ship and never revisit. Model it now. |
Red flags โ STOP
- Reaching for
dict[str, Any], dict[str, object], or Any for "config"/"options"/"metadata"/"payload".
- A class or TypedDict where most fields are
| None or NotRequired.
cast() or # type: ignore on anything other than narrowing the checker provably can't do.
- A
bool/status field that decides which other fields are meaningful โ that's a discriminant; make it a union.