3.7 KiB
Typing
Use types to describe accepted shapes and behavioral contracts, not to pretend Python is a different language.
Why
Good Python typing improves APIs in two ways:
- callers can see which shapes are accepted
- maintainers can preserve real boundaries without smearing
Anyeverywhere
The mature pattern is not “make everything maximally abstract.” It is:
- use structural typing when capability matters more than inheritance
- use explicit aliases and unions for ergonomic public inputs
- keep public APIs typed even when internals stay dynamic
That gives users a real contract without freezing implementation choices too early.
The pattern
- Type public APIs precisely.
- Prefer
Protocolwhen callers care about behavior, not ancestry. - Use explicit unions and aliases for user-facing flexibility.
- Keep dynamic internals from leaking into the public contract.
- Avoid
Anyunless you truly mean “anything goes.”
When to use
Use this when:
- multiple implementations can satisfy one behavioral need
- callers naturally have more than one valid input shape
- you want strong editor and type-checker help at public boundaries
- internals are dynamic but the public contract is still stable
When not to use
Do not use a protocol when a concrete type is the real contract.
Do not use broad unions just to avoid choosing a better API.
Do not over-trust @runtime_checkable: CPython is explicit that runtime protocol checks verify only attribute presence, not signature correctness.
Preferred shapes
Structural typing for capability-based contracts
class Writer(Protocol):
def write(self, data: bytes) -> int: ...
If the caller only needs write(), do not require a specific base class.
Explicit flexible public inputs
URLInput = URL | str
This is better than either extreme:
- forcing callers to pre-wrap everything
- accepting
Anyand hoping for the best
Counterexamples
Inheritance-only abstraction
class BaseStore:
...
def persist(store: BaseStore) -> None:
...
This is too rigid when the function only needs a small capability surface.
Type surrender
def send(data: Any, options: Any) -> Any:
...
The API contract disappeared.
Runtime protocol overconfidence
If runtime safety matters, attribute-presence checks are not enough. Protocols do most of their work at static-analysis time.
Source signals
CPython / typing
Lib/typing.py:2132-2157definesProtocolaround structural subtyping and explicitly frames it as static duck typing.Lib/typing.py:2155-2157states that@runtime_checkableprotocols check only attribute presence, ignoring type signatures.Lib/typing.py:2190-2250showsAnnotatedas “type plus metadata,” not a new underlying runtime type.
HTTPX
httpx/_client.py:639-660givesClientprecise constructor types for auth, params, headers, cookies, timeouts, transports, andbase_url.httpx/_client.py:1353-1374mirrors that precision onAsyncClientinstead of collapsing to untyped arguments.
Pydantic
pydantic/main.py:156-205exposes typedClassVar[...]metadata for config, fields, serializer, and validator state even though framework internals are dynamic.pydantic/main.py:253-264makes model construction validate**data: Anyimmediately instead of pretending arbitrary inputs are already safe.pydantic/main.py:721-768givesmodel_validate(...) -> Selfan explicit typed boundary contract.
Bottom line
Use typing to make public boundaries clearer.
Be flexible where callers need flexibility.
Be precise where contracts matter.
Do not hide uncertainty behind Any.