Files

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 Any everywhere

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

  1. Type public APIs precisely.
  2. Prefer Protocol when callers care about behavior, not ancestry.
  3. Use explicit unions and aliases for user-facing flexibility.
  4. Keep dynamic internals from leaking into the public contract.
  5. Avoid Any unless 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 Any and 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-2157 defines Protocol around structural subtyping and explicitly frames it as static duck typing.
  • Lib/typing.py:2155-2157 states that @runtime_checkable protocols check only attribute presence, ignoring type signatures.
  • Lib/typing.py:2190-2250 shows Annotated as “type plus metadata,” not a new underlying runtime type.

HTTPX

  • httpx/_client.py:639-660 gives Client precise constructor types for auth, params, headers, cookies, timeouts, transports, and base_url.
  • httpx/_client.py:1353-1374 mirrors that precision on AsyncClient instead of collapsing to untyped arguments.

Pydantic

  • pydantic/main.py:156-205 exposes typed ClassVar[...] metadata for config, schema, fields, serializer, and validator state even though framework internals are dynamic.
  • pydantic/main.py:167-168 documents a synthesized __init__ signature, which is another signal that annotations shape the runtime boundary.
  • pydantic/main.py:253-264 makes model construction validate **data: Any immediately instead of pretending arbitrary inputs are already safe.
  • pydantic/main.py:721-768 gives model_validate(...) -> Self an explicit typed boundary contract with runtime validation options.
  • 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.