Protocols: Structural Subtyping
James wants to test SmartNotes without a real database. He has been using an InMemoryRepository that stores notes in a dictionary. It works for testing. But his functions accept InMemoryRepository as the type hint, which means they cannot accept a DatabaseRepository later without changing every type annotation.
"Define what storage looks like, not what it is," Emma says. "You do not care if the repository is in-memory, on disk, or in a database. You care that it has save() and find_by_id(). That is a Protocol."
An interface defines what methods an object must have, without saying how those methods work. A Protocol is Python's way of defining interfaces based on structure: if a class has the right methods with the right signatures, it satisfies the Protocol automatically. No inheritance required.
Python's Protocol (from typing, available since 3.8) enables structural subtyping (duck typing with static type checking). Unlike ABC, which uses nominal subtyping (requires explicit inheritance), Protocol checks only that the class has the required method signatures. This aligns with Python's duck typing philosophy: "if it has save() and find_by_id(), it is a Repository."
ABC Recap (From Chapter 59)
In Chapter 59 Lesson 4, you learned Abstract Base Classes:
from abc import ABC, abstractmethod
class NoteType(ABC):
@abstractmethod
def render(self) -> str: ...
To satisfy NoteType, a class must inherit from it:
class TextNote(NoteType): # ← must say (NoteType)
def render(self) -> str:
return self.body
This is nominal subtyping: the class declares its lineage. Python checks the family tree.
Protocol: Check Structure, Not Lineage
A Protocol defines the required methods without requiring inheritance:
from typing import Protocol
class Repository(Protocol):
def save(self, note: "Note") -> None: ...
def find_by_id(self, note_id: str) -> "Note | None": ...
Any class with save() and find_by_id() with matching signatures satisfies this Protocol automatically:
class InMemoryRepository:
# No inheritance from Repository!
def __init__(self) -> None:
self._storage: dict[str, "Note"] = {}
def save(self, note: "Note") -> None:
self._storage[note.title] = note
def find_by_id(self, note_id: str) -> "Note | None":
return self._storage.get(note_id)
def count_notes(repo: Repository) -> int:
# Accepts ANY object with save() and find_by_id()
return len(repo._storage)
repo = InMemoryRepository()
repo.save(Note("Test", "Body"))
print(count_notes(repo))
Output:
1
InMemoryRepository does not inherit from Repository. It satisfies the Protocol because it has the right methods with the right signatures. Pyright verifies this at type-checking time.
Protocol vs ABC
| Aspect | ABC (Ch 59) | Protocol (This Lesson) |
|---|---|---|
| Subtyping | Nominal (checks family tree) | Structural (checks method signatures) |
| Inheritance required? | Yes: class X(MyABC) | No: just have the right methods |
| Enforcement | Runtime: TypeError if abstract method missing | Static: pyright catches mismatches |
| Best for | Shared implementation (default methods, mixins) | Interfaces for dependency injection and testing |
| Python philosophy | Java-style explicit contracts | Duck typing with type safety |
When to Use Each
| Scenario | Use |
|---|---|
| You want to share default implementations across subclasses | ABC |
| You want to swap implementations for testing (mocks, fakes) | Protocol |
| Third-party classes need to satisfy your interface | Protocol (they cannot inherit from your ABC) |
| You need runtime enforcement (fail at instantiation) | ABC |
James's analogy: you do not care if a vendor is a subsidiary of your company (inheritance). You care if they can deliver on time and meet quality standards (Protocol). Any vendor that meets the spec qualifies, regardless of corporate lineage.
Dependency Injection with Protocol
The real power of Protocol is dependency injection: writing functions that accept any implementation matching the interface.
def save_and_verify(repo: Repository, note: Note) -> bool:
"""Save a note and verify it was stored."""
repo.save(note)
found = repo.find_by_id(note.title)
return found is not None
This function works with InMemoryRepository for testing and DatabaseRepository for production. The function does not know or care which implementation it receives. It only knows the interface.
# In tests:
test_repo = InMemoryRepository()
result = save_and_verify(test_repo, Note("Test", "Body"))
print(result)
# In production (same function, different repo):
# prod_repo = DatabaseRepository(connection_string)
# save_and_verify(prod_repo, real_note)
Output:
True
PRIMM-AI+ Practice: Protocol Prediction
Predict [AI-FREE]
Press Shift+Tab to enter Plan Mode before predicting.
Read this code and predict: does FileLogger satisfy Logger? Does ConsoleLogger satisfy Logger? Write your prediction and a confidence score from 1 to 5.
from typing import Protocol
class Logger(Protocol):
def log(self, message: str) -> None: ...
def get_logs(self) -> list[str]: ...
class FileLogger:
def log(self, message: str) -> None:
with open("log.txt", "a") as f:
f.write(message + "\n")
def get_logs(self) -> list[str]:
with open("log.txt") as f:
return f.readlines()
class ConsoleLogger:
def log(self, message: str) -> None:
print(message)
Check your prediction
- FileLogger: Yes, satisfies
Logger. It has bothlog(message: str) -> Noneandget_logs() -> list[str]with matching signatures. - ConsoleLogger: No, does not satisfy
Logger. It haslog()but is missingget_logs(). Pyright would flag this if you tried to passConsoleLoggerwhereLoggeris expected.
Run
Press Shift+Tab to exit Plan Mode.
Create a file called protocol_practice.py. Write the Logger Protocol and both classes above. Write a function process(logger: Logger) -> None that calls both methods. Try passing both loggers to it. Run uv run pyright protocol_practice.py to see the type error on ConsoleLogger.
Investigate
In Claude Code, type:
/investigate @protocol_practice.py
Ask: "How would I fix ConsoleLogger to satisfy the Logger Protocol? What is the minimum change needed? Would adding get_logs as a dummy method be acceptable?"
Try With AI
Prompt 1: Design a Repository Protocol
I need a Repository Protocol for my SmartNotes app.
It should support these operations:
- save(note) -- store a note
- find_by_id(note_id) -- return a note or None
- find_all() -- return all notes
- delete(note_id) -- remove a note
Write the Protocol definition, then write InMemoryRepository
that satisfies it using a dictionary. Include type hints.
Do NOT make InMemoryRepository inherit from Repository.
Read the AI's implementation. Verify that InMemoryRepository has all four methods with matching signatures and does not inherit from Repository.
What you're learning: You are seeing the Protocol pattern applied to a complete interface, not just a minimal example. The AI designs the interface, but you verify structural compliance.
Prompt 2: Protocol vs ABC Tradeoff
I have an ABC called NoteType from Chapter 59 with a
default render() method. I also need a Repository
Protocol for testing.
When should I use ABC and when Protocol? Can I use
both in the same project? Show me a concrete example
where mixing both makes sense.
What you're learning: ABC and Protocol are not competing tools; they solve different problems. The AI helps you see when each is appropriate, and you evaluate whether its reasoning matches the decision table from this lesson.
Prompt 3: Protocol for Your Domain
I work in [describe your professional domain]. I need
to write tests for [describe a component: payment
processing, report generation, notification sending,
etc.] without connecting to the real service.
Define a Protocol for this service interface and write
a fake implementation I can use in tests. Explain why
Protocol is better than ABC for this testing scenario.
What you're learning: You are transferring the Protocol + dependency injection pattern to your own domain. The AI generates the Protocol and fake implementation, but you evaluate whether the interface captures the essential operations and whether the fake is realistic enough for testing.
James looks at the Repository Protocol and InMemoryRepository. "So I define what storage looks like with Protocol. Any class with the right methods qualifies. No inheritance needed."
"Right. And when you write functions that accept Repository, they work with any implementation. Your tests use InMemoryRepository. Production uses the real database. The function does not change."
"In the warehouse, we called this vendor qualification," James says. "We did not care if a supplier was a division of a parent company. We cared if they could deliver pallets on time, meet our packing standards, and handle returns. Those were the three requirements. Any supplier meeting those three qualified."
Emma nods. "That is structural subtyping. The supplier qualifies by meeting the requirements, not by being in the right corporate family."
"I still reach for ABC first," Emma admits. "Protocol is newer, and I have not fully internalized the structural approach. When I design an interface, my first instinct is class X(ABC). Then I ask myself: does the implementing class need to know about this interface? If the answer is no, especially for testing, I refactor to Protocol. I am getting better at starting with Protocol, but it is still not my default."
"You have every OOP tool now. Properties compute values. Static and class methods organize functionality. Decorators modify behavior. Protocols define interfaces. Next lesson: put them all together."