Warming up the neural circuits...
By the end of this chapter you will:
Most teams stop at str and int annotations and think typing is done. That leaves large bug classes untouched:
Any spread that kills type checker value.Advanced typing gives you maintainability under change, not just prettier signatures.
from typing import Protocol
class EventSink(Protocol):
def send(self, event: str) -> None:
...
def publish(sink: EventSink, payload: str) -> None:
sink.send(payload)Any object with send(str) -> None is accepted, even without inheriting a shared base class.
from typing import TypeVar
SupportsLessThan = TypeVar("SupportsLessThan", bound="Comparable")
class Comparable:
def __lt__(self, other: object) -> bool:
raise NotImplementedError
from typing import NotRequired, TypedDict
class UserEvent(TypedDict):
id: str
type: str
email: NotRequired[str]
ip: NotRequired[str]Use strict required keys for invariants and NotRequired for gradual adoption fields.
from typing import Literal, NewType
UserId = NewType("UserId", str)
Currency = Literal["INR", "USD", "EUR"]
def invoice(user_id: UserId, currency
[tool.mypy]
python_version = "3.12"
warn_unused_ignores = true
no_implicit_optional = true
[[tool.mypy.overrides]]
module = ["src.core.*", "src.services.*"]
disallow_untyped_defs = true
Type strictness should follow business risk. Start where wrong types can create money, data, or security incidents.
| Need | Prefer |
|---|---|
| Replace inheritance-heavy interfaces | Protocol |
| Preserve type across helper functions | TypeVar generics |
| Strong payload schema for dict data | TypedDict |
| Distinguish semantically different primitive IDs | NewType |
| Safe team-wide rollout | Incremental mypy override ladder |
| Mistake | Why it hurts | Better move |
|---|---|---|
Any at core boundaries | Type checker loses leverage immediately | Use Protocol/TypedDict contracts |
| Global strict mode in one step | Team blocked by low-value noise | Strictness by module and risk |
| Untyped third-party wrappers | Hidden bugs crossing boundaries | Add typed adapter layer |
| Over-modeling simple script code | Cognitive overhead | Keep strictness proportional to complexity |
| Ignoring type ignores drift | Dead suppressions hide regressions | Enable warn_unused_ignores |
send(message: str) method and use it in a service function.A service must support email and SMS sendersAny sender implementation matching behavior is acceptedid and optional trace_id fields.Event payload dictionaryTyped payload contractComparable valuesReturn type preserved as input typecore, api, and legacy.Mixed-quality repositoryCore strict first, legacy gradualArgument has incompatible type mypy error in service boundaries.Wrong payload shape passed to typed functionAdapter, narrowing, or contract fix describedBeginner:
"When should I use Protocol over inheritance?"
Use Protocol when behavior compatibility matters more than class lineage.
"Why use TypedDict when dataclass exists?"
TypedDict models dictionary payloads for static checking without converting runtime representation.
Senior:
"How do you prevent type-checking fatigue in large teams?"
Roll out strictness by business-critical modules, track false positives, and keep suppression hygiene tight.
"How do you type untyped third-party SDK boundaries?"
Isolate SDK calls behind typed adapters and validate boundary contracts before data enters core domain logic.
Behavior contract? -> Protocol
Payload shape? -> TypedDict
Reusable helper with type preservation? -> TypeVar
Safe rollout? -> strictness ladder by moduleWhat is the main advantage of Protocol in Python typing?
Bounds protect generic helpers from accepting unsupported types.
This prevents accidentally swapping same-primitive but different-domain values.
Raise strictness in core domains first, then expand gradually.