Protocols
Protocols enable structural subtyping in Python. Instead of inheritance, you define what methods an object must have, and any object with those methods is considered compatible. This is Python's approach to what other languages call "interfaces" or "duck typing with type safety."
The Problem: Duck Typing vs. ABCs
Traditional Python uses duck typing:
def process(data):
return data.send() # Works if it has .send(), fails otherwise
But this gives no type hints. Abstract Base Classes (ABCs) require inheritance, which is rigid:
from abc import ABC, abstractmethod
class Sender(ABC):
@abstractmethod
def send(self, msg):
pass
# Must inherit from Sender - too restrictive!
class Email(Sender):
def send(self, msg):
print(f"Email: {msg}")
Protocol Basics
A Protocol defines what methods a type must have, without requiring inheritance:
from typing import Protocol
class Sender(Protocol):
def send(self, msg: str) -> None:
...
# Any class with .send() method satisfies the Protocol
class Email:
def send(self, msg: str) -> None:
print(f"Email: {msg}")
class SMS:
def send(self, msg: str) -> None:
print(f"SMS: {msg}")
def notify(sender: Sender, msg: str) -> None:
sender.send(msg)
notify(Email(), "Hello") # Works!
notify(SMS(), "Hello") # Works!
The type checker verifies that objects have the required methods at static time.
Runtime Protocol Checks
Use runtime_checkable for isinstance() checks:
from typing import Protocol, runtime_checkable
@runtime_checkable
class Readable(Protocol):
def read(self) -> str:
...
class File:
def read(self) -> str:
return "file content"
class StringBuffer:
def read(self) -> str:
return "buffer content"
print(isinstance(File(), Readable)) # True
print(isinstance(StringBuffer(), Readable)) # True
print(isinstance("string", Readable)) # False
runtime_checkable when you need isinstance() checks. It bypasses some optimizations and can cause surprising behavior at runtime.
Protocol Inheritance
Protocols can inherit from other Protocols:
from typing import Protocol
class Readable(Protocol):
def read(self) -> str:
...
class Writable(Protocol):
def write(self, data: str) -> None:
...
# Combines both - must implement read() AND write()
class ReadWrite(Readable, Writable):
def read(self) -> str:
return ""
def write(self, data: str) -> None:
pass
def process(io: ReadWrite) -> None:
data = io.read()
io.write(data.upper())
Self Type in Protocols
Use Self for methods that return the same type:
from typing import Protocol, Self
class Builder(Protocol):
def build(self) -> Self:
...
class ConfigBuilder:
def build(self) -> Self:
return self
def with_option(self, key: str, value: str) -> Self:
# Returns same type for chaining
self.options[key] = value
return self
builder = ConfigBuilder().with_option("host", "localhost").build()
Composition Over Inheritance
Protocols shine when composing behaviors without inheritance hierarchies:
from typing import Protocol
class JSONSerializable(Protocol):
def to_json(self) -> str:
...
class Loggable(Protocol):
def log(self) -> str:
...
# A class can satisfy multiple protocols without inheritance
class User:
def __init__(self, name: str, email: str):
self.name = name
self.email = email
def to_json(self) -> str:
return '{"name": "' + self.name + '", "email": "' + self.email + '"}'
def log(self) -> str:
return f"User: {self.name} <{self.email}>"
# Works with both Protocol types
def serialize(obj: JSONSerializable) -> str:
return obj.to_json()
def log_message(obj: Loggable) -> str:
return obj.log()
Protocols vs ABCs
| Aspect | Protocols | ABC |
|---|---|---|
| Inheritance | None required | Must inherit |
| Type checking | Static (mypy/pyright) | Static + runtime |
| isinstance() | With @runtime_checkable | Always |
| Implementation | Implicit (has the methods) | Explicit (@abstractmethod) |
| Composition | Easy (multiple inheritance) | Requires mixins |
Practical Example: Repository Pattern
from typing import Protocol, TypeVar, Generic
T = TypeVar("T")
class Repository(Protocol[T]):
def get(self, id: int) -> T | None:
...
def save(self, entity: T) -> T:
...
def delete(self, id: int) -> None:
...
class User:
def __init__(self, id: int, name: str):
self.id = id
self.name = name
# Any class with these methods works - no inheritance needed!
class InMemoryUserRepo:
def __init__(self):
self._store: dict[int, User] = {}
def get(self, id: int) -> User | None:
return self._store.get(id)
def save(self, user: User) -> User:
self._store[user.id] = user
return user
def delete(self, id: int) -> None:
self._store.pop(id, None)
# Works with any repository implementation
def create_user(repo: Repository[User], name: str) -> User:
user = User(id=len(repo._store) + 1, name=name)
return repo.save(user)