3.9 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, schema, fields, serializer, and validator state even though framework internals are dynamic.pydantic/main.py:167-168documents a synthesized__init__signature, which is another signal that annotations shape the runtime boundary.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 with runtime validation options.
Related comparison
comparison/pydantic-vs-python.md
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.