Zum Hauptinhalt springen

Chapter 19 — Protocols and Structural Subtyping

1. Opening problem

A service needs an object that can send a message.

Nominal design:

class MessageSenderBase:
def send(
self,
recipient: str,
message: str,
) -> None:
raise NotImplementedError

Every implementation must inherit from the base.

Structural design:

from typing import Protocol


class MessageSender(
Protocol
):
def send(
self,
recipient: str,
message: str,
) -> None:
...

Any object with a compatible send method satisfies the protocol statically, even without inheritance.

This is static duck typing.


2. Nominal versus structural subtyping

Nominal subtyping asks:

Did the class explicitly inherit or declare a relationship?

Structural subtyping asks:

Does the object provide the required shape and behavior?

TypeScript interfaces are structurally typed.

Python protocols bring a similar concept to static analysis while preserving Python's runtime duck-typing tradition.

Example implementation:

class EmailSender:
def send(
self,
recipient: str,
message: str,
) -> None:
...

No protocol inheritance is required.

def notify(
sender: MessageSender,
) -> None:
sender.send(
"steve@example.com",
"Welcome",
)

A type checker accepts EmailSender when its method is compatible.


3. Small protocols

Good:

class SupportsClose(
Protocol
):
def close(self) -> None:
...

Potentially excessive:

class EnterpriseRepository(
Protocol
):
def find(...): ...
def find_all(...): ...
def save(...): ...
def delete(...): ...
def begin(...): ...
def commit(...): ...
def rollback(...): ...
def refresh(...): ...
def lock(...): ...

Large protocols:

  • reduce substitutability;
  • make fakes expensive;
  • couple unrelated operations;
  • create interface segregation problems.

Prefer capability-oriented protocols.


4. Protocol attributes

class HasName(
Protocol
):
name: str

Compatible class:

class User:
def __init__(
self,
name: str,
) -> None:
self.name = name

Attribute mutability matters.

A writable protocol attribute may reject a read-only property because callers are allowed to assign.

Read-only protocol property:

class HasName(
Protocol
):
@property
def name(self) -> str:
...

Now a compatible implementation only needs readable access.

Design protocol mutability intentionally.


5. Method compatibility

Protocol:

class Loader(
Protocol
):
def load(
self,
key: str,
) -> bytes:
...

Implementation:

class FileLoader:
def load(
self,
key: str,
) -> bytes:
...

Compatibility considers:

  • parameter types;
  • return types;
  • positional and keyword behavior;
  • optional parameters;
  • variance;
  • overloads;
  • properties and mutability.

A method with a narrower accepted parameter may not be compatible.


6. Protocol inheritance

class Readable(
Protocol
):
def read(self) -> bytes:
...


class Closable(
Protocol
):
def close(self) -> None:
...


class ReadableResource(
Readable,
Closable,
Protocol,
):
pass

This composes capabilities.

Do not use protocol inheritance to create deep conceptual taxonomies. Keep contracts focused.


7. Generic protocols

class Source[T](
Protocol
):
def get(self) -> T:
...
class Repository[ID, T](
Protocol
):
def find(
self,
entity_id: ID,
) -> T | None:
...

def save(
self,
entity: T,
) -> None:
...

Generic protocols express relationships without requiring implementation inheritance.

Use variance deliberately for producer-only or consumer-only protocols.


8. Callback protocols

Callable cannot describe every callback shape.

class ErrorReporter(
Protocol
):
def __call__(
self,
message: str,
*,
code: int | None = None,
) -> None:
...

Compatible function:

def report_error(
message: str,
*,
code: int | None = None,
) -> None:
...

Callback protocols can express:

  • keyword-only parameters;
  • overloaded calls;
  • generic methods;
  • named arguments;
  • attributes on callable objects.

9. Explicit protocol inheritance

An implementation may explicitly inherit:

class EmailSender(
MessageSender
):
...

Benefits:

  • documents intention;
  • missing abstract protocol members may be reported;
  • shared protocol defaults can be inherited.

Costs:

  • creates nominal coupling;
  • may affect MRO;
  • is unnecessary for structural compatibility.

Explicit inheritance is useful when implementer intent should be visible.

It is not required.


10. Default protocol methods

class Named(
Protocol
):
@property
def name(self) -> str:
...

def display_name(
self,
) -> str:
return self.name.title()

A structurally compatible class that does not inherit from Named does not automatically receive display_name.

Default implementation is inherited only by explicit subclasses.

Protocols primarily describe contracts, not reusable mixin behavior.

Use a helper function or mixin when code reuse is the real goal.


11. runtime_checkable

from typing import (
Protocol,
runtime_checkable,
)


@runtime_checkable
class SupportsClose(
Protocol
):
def close(self) -> None:
...

Now:

isinstance(
resource,
SupportsClose,
)

can perform a limited structural runtime check.

Limitations:

  • generally checks attribute presence, not full signatures;
  • does not validate parameter or return types;
  • can be slower than normal nominal checks;
  • dynamic objects may behave unexpectedly;
  • passing the check does not prove semantic correctness.

Do not confuse runtime-checkable protocols with complete runtime validation.


12. Semantic contracts

An object may structurally match:

class Transaction(
Protocol
):
def commit(self) -> None:
...

def rollback(self) -> None:
...

But methods may not provide correct transaction semantics.

Static structure cannot verify:

  • atomicity;
  • durability;
  • idempotency;
  • authorization;
  • ordering;
  • side effects;
  • performance;
  • exception behavior.

Documentation and tests remain essential.


13. Protocols versus ABCs

Use a protocol when:

  • structural compatibility is desired;
  • third-party objects should work without modification;
  • fakes should be simple;
  • behavior matters more than identity;
  • static checking is the main requirement.

Use an ABC when:

  • runtime nominal identity matters;
  • shared implementation exists;
  • instantiation must be restricted;
  • framework registration depends on inheritance;
  • class-level lifecycle is required.

They can also be combined, but avoid duplicate abstractions without purpose.


14. Protocols versus concrete types

Do not create a protocol automatically for every dependency.

Concrete type:

def render(
template: JinjaTemplate,
) -> str:
...

may be appropriate when the function genuinely depends on Jinja-specific behavior.

Protocol:

class Template(
Protocol
):
def render(
self,
context: dict[str, object],
) -> str:
...

is valuable when implementations are expected to vary or tests benefit from a smaller contract.

The protocol should be owned near the consumer, not necessarily near the implementation.


15. Consumer-owned interfaces

Suppose an application service needs:

class UserLookup(
Protocol
):
def find_email(
self,
user_id: int,
) -> str | None:
...

This protocol belongs to the application use case.

A large database repository may implement the capability implicitly.

Why consumer-owned?

  • contract stays minimal;
  • use case controls what it needs;
  • infrastructure does not dictate domain abstractions;
  • test fakes remain small.

This is an important dependency-inversion principle.


16. Testing with protocol-shaped fakes

class FakeUserLookup:
def __init__(
self,
emails: dict[int, str],
) -> None:
self._emails = emails

def find_email(
self,
user_id: int,
) -> str | None:
return self._emails.get(
user_id
)

No framework base class is required.

The fake can implement only the needed capability.

This reduces test coupling.


17. Protocol property variance

Problem:

class Box(
Protocol
):
content: object

An implementation with content: str may be unsafe if callers can assign any object through the protocol.

Read-only property:

class Box(
Protocol
):
@property
def content(
self,
) -> object:
...

A string-returning property can be compatible because callers only read it.

Mutable attributes force invariance-like reasoning.


18. Recursive protocols

class TreeNode(
Protocol
):
@property
def children(
self,
) -> list["TreeNode"]:
...

Recursive protocols can model trees and graphs.

Consider using read-only sequences rather than mutable lists:

from collections.abc import Sequence


class TreeNode(
Protocol
):
@property
def children(
self,
) -> Sequence["TreeNode"]:
...

This improves variance and prevents callers from mutating implementation-owned collections.


19. Modules as protocols

Python typing can conceptually treat modules as satisfying protocols because modules have attributes.

Example dependency:

class Clock(
Protocol
):
def now(self) -> datetime:
...

A module exposing now() may satisfy the same structural contract in suitable contexts.

In practice, small objects are often easier to inject and test than modules.


20. Built-in protocols and ABCs

Prefer standard abstractions when they already express the requirement:

from collections.abc import (
Iterable,
Iterator,
Mapping,
Sequence,
)

Instead of defining:

class CanIterate(
Protocol
):
def __iter__(self):
...

Use Iterable[T].

Standard protocols improve interoperability and reader familiarity.


21. TypeScript comparison

TypeScript interfaces are structurally typed by default.

Python protocols are opt-in static descriptions within a dynamically typed language.

Differences:

  • runtime classes remain independent unless explicitly inherited;
  • runtime_checkable is limited;
  • Python method binding and descriptor behavior affect compatibility;
  • multiple checker implementations may report edge cases differently;
  • protocols should align with Python's existing runtime duck typing.

22. Production case study: storage dependency

Overly broad dependency:

class StorageService(
Protocol
):
def upload(...): ...
def download(...): ...
def delete(...): ...
def list(...): ...
def generate_url(...): ...
def change_permissions(...): ...

A report exporter only needs:

class ByteWriter(
Protocol
):
def write(
self,
path: str,
content: bytes,
) -> None:
...

Use case:

class ReportExporter:
def __init__(
self,
writer: ByteWriter,
) -> None:
self._writer = writer

Infrastructure adapters can satisfy the narrow contract.


23. Common mistakes

Protocol for every class

Create one only when abstraction adds value.

Large protocol

Split capabilities.

Using protocol as mixin

Structural implementers do not inherit default code.

runtime_checkable as validation

It checks limited structure.

Writable attributes without intent

Use properties for read-only requirements.

Infrastructure-owned contract

Let consumers define minimal needs.

Recreating standard protocols

Use Iterable, Mapping, and similar abstractions.

Ignoring semantic behavior

Structure does not prove correctness.


24. English vocabulary

TermMeaning
protocolstructural behavioral contract
structural subtypingcompatibility based on members
nominal subtypingcompatibility based on declared inheritance
capabilitysmall unit of supported behavior
consumer-owned interfacecontract defined by the code using it
runtime-checkablesupporting limited isinstance checks
semantic contractbehavioral meaning beyond method shape
adapterobject translating one interface to another
interface segregationkeeping contracts focused
implicit implementationsatisfying a protocol without inheritance

Useful sentences:

  • “The implementation satisfies the protocol structurally.”
  • “The protocol is owned by the consuming use case.”
  • “This writable attribute makes the contract unnecessarily restrictive.”
  • “The runtime check verifies member presence, not signatures.”
  • “The protocol describes a capability rather than a hierarchy.”
  • “The structural match does not guarantee transaction semantics.”

25. Speaking task

Explain for twelve minutes:

How Python protocols compare with TypeScript interfaces.

Include nominal typing, structural typing, runtime checks, and consumer-owned contracts.


26. Writing task

Write a 500-word architecture review of a codebase with one 40-method repository protocol implemented by every persistence adapter and fake.


27. Exercises

Exercise 1

Create a small protocol for writing bytes.

Exercise 2

Create a callback protocol with keyword-only severity.

Exercise 3

Create a generic read-only Source.

Exercise 4

Demonstrate the limits of runtime_checkable.

Exercise 5

Refactor a large service interface into consumer-owned capabilities.

Exercise 6

Choose between a protocol, ABC, and concrete type in three scenarios.


28. Complete solutions

Solution 1

from typing import Protocol


class ByteWriter(
Protocol
):
def write(
self,
path: str,
content: bytes,
) -> None:
...

Solution 2

from typing import (
Literal,
Protocol,
)

Severity = Literal[
"info",
"warning",
"error",
]


class Reporter(
Protocol
):
def __call__(
self,
message: str,
*,
severity: Severity,
) -> None:
...

Solution 3

from typing import Protocol


class Source[T](
Protocol
):
def get(self) -> T:
...

Because the protocol only produces T, covariance may be inferred or declared explicitly in compatibility syntax depending on target tooling.

Solution 4

from typing import (
Protocol,
runtime_checkable,
)


@runtime_checkable
class HasRun(
Protocol
):
def run(
self,
value: int,
) -> str:
...


class Incorrect:
def run(self) -> None:
pass


print(
isinstance(
Incorrect(),
HasRun,
)
)

The runtime structural check may pass because a run attribute exists, even though the signature and return contract are incompatible.

Static checking remains essential.

Solution 5

Instead of one large repository, define:

class UserReader(
Protocol
):
def find_user(
self,
user_id: int,
) -> User | None:
...


class UserWriter(
Protocol
):
def save_user(
self,
user: User,
) -> None:
...


class UnitOfWork(
Protocol
):
def commit(self) -> None:
...

Each use case depends only on the capabilities it needs.

Solution 6

Use a protocol for a third-party-compatible clock dependency.

Use an ABC for a framework plugin requiring runtime registration and shared implementation.

Use a concrete type when a function intentionally depends on one library's specialized API and substitution is not needed.


29. Chapter checkpoint

You should now be able to explain:

  1. nominal versus structural subtyping;
  2. implicit protocol implementation;
  3. small capability protocols;
  4. protocol attributes;
  5. generic protocols;
  6. callback protocols;
  7. default protocol methods;
  8. runtime_checkable limitations;
  9. protocols versus ABCs;
  10. consumer-owned contracts;
  11. testing with structural fakes;
  12. semantic contracts.