Type Annotations and Pydantic Best Practices
Core Philosophy
Use Pydantic models for structured data. Use specific types everywhere. Never use Any or raw dicts when structure is known.
Quick Start - Fixing Type Errors
1. Run type checker
pyright <file-or-directory>
or
basedpyright <file-or-directory>
2. Fix errors (see patterns below and reference files)
3. Verify no behavior changes
pytest tests/
Fix Priority Order
-
Add proper type annotations (Optional, specific types)
-
Fix decorator return types
-
Use cast() for runtime-compatible but statically unverifiable types
-
Last resort: # type: ignore only for legitimate cases
Type Annotation Rules
Modern Python Syntax (3.9+)
CORRECT
def process_data(items: list[str]) -> dict[str, list[int]]: results: dict[str, list[int]] = {} return results
WRONG - Old style
from typing import Dict, List def process_data(items: List[str]) -> Dict[str, List[int]]: ...
NEVER Use Weak Types
NEVER
items: list[Any] data: dict result: Any
ALWAYS use specific types
items: list[DataItem] data: dict[str, ProcessedResult] result: SpecificType | OtherType
NEVER Use hasattr/getattr as Type Substitutes
WRONG - Type cop-out
def process(obj: Any): if hasattr(obj, "name"): return obj.name
CORRECT - Use Protocol
class Named(Protocol): name: str
def process(obj: Named) -> str: return obj.name
Complex Return Types Must Be Named
WRONG - Unreadable
def execute() -> tuple[BatchResults, dict[str, Optional[Egress]]]: pass
CORRECT - Named model
class ExecutionResult(BaseModel): batch_results: BatchResults egress_statuses: dict[str, Optional[Egress]]
def execute() -> ExecutionResult: pass
Rule: If you can't read the type annotation out loud in one breath, it needs a named model.
Pydantic Rules
Always Use Pydantic Models for Structured Data
WRONG - Raw dict
def get_result() -> dict[str, Any]: return {"is_valid": True, "score": 0.95}
CORRECT - Pydantic model
class Result(BaseModel): is_valid: bool score: float
def get_result() -> Result: return Result(is_valid=True, score=0.95)
TypedDict and dataclasses Are Prohibited
NEVER use TypedDict or dataclasses without explicit authorization. Always use Pydantic.
Never Convert Models to Dicts Just to Add Fields
WRONG - Breaking type safety
result_dict = result.model_dump() result_dict["_run_id"] = run_id # Now untyped!
CORRECT - Extend the model
class ResultWithRunId(BaseModel): details: ResultDetails run_id: str | None = None
Prefer cast() Over type: ignore
from typing import cast
Preferred
typed_results = cast(list[ResultProtocol], results) selected = select_by_score(typed_results)
Less clear
selected = select_by_score(results) # type: ignore[arg-type]
When to Use type: ignore
Only for:
-
Function attributes: func._attr = val # type: ignore[attr-defined]
-
Dynamic/runtime attributes not in type system
-
External library quirks (protobuf, webhooks)
-
Legacy patterns requiring significant refactoring
DO NOT use for simple fixes (add Optional, fix return types).
Reference Files
For detailed patterns:
-
references/pydantic-patterns.md - Pydantic patterns and external system integration
-
references/expanding-coverage.md - How to add new modules to type checking
Related Skills
-
/pythonista-testing - Testing typed code
-
/pythonista-reviewing - Type issues in code review
-
/pythonista-async - Async type patterns