Skip to main content

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."


If you're new to programming

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.

If you've coded before

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

AspectABC (Ch 59)Protocol (This Lesson)
SubtypingNominal (checks family tree)Structural (checks method signatures)
Inheritance required?Yes: class X(MyABC)No: just have the right methods
EnforcementRuntime: TypeError if abstract method missingStatic: pyright catches mismatches
Best forShared implementation (default methods, mixins)Interfaces for dependency injection and testing
Python philosophyJava-style explicit contractsDuck typing with type safety

When to Use Each

ScenarioUse
You want to share default implementations across subclassesABC
You want to swap implementations for testing (mocks, fakes)Protocol
Third-party classes need to satisfy your interfaceProtocol (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 both log(message: str) -> None and get_logs() -> list[str] with matching signatures.
  • ConsoleLogger: No, does not satisfy Logger. It has log() but is missing get_logs(). Pyright would flag this if you tried to pass ConsoleLogger where Logger is 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."