Python - Python Protocols and Structural Subtyping
Introduction
Python protocols and structural subtyping provide a way to define how objects should behave without requiring them to inherit from a particular class. In traditional object-oriented programming, a class usually follows an interface by explicitly inheriting from a base class. Structural subtyping takes a different approach: an object is considered compatible with an interface when it provides the required methods and attributes, regardless of whether it explicitly inherits from that interface.
Python supports this approach through the typing.Protocol class. Protocols are especially useful when developing large applications because they allow different and unrelated classes to work with the same piece of code as long as they provide the expected behavior.
What Is a Protocol?
A protocol is a specification that describes the methods and attributes an object should provide. It focuses on what an object can do rather than what type of object it is.
For example, suppose an application needs an object that has a send() method. Instead of requiring every object to inherit from a specific Sender class, we can define a protocol that specifies the existence of the send() method.
from typing import Protocol
class Sender(Protocol):
def send(self, message: str) -> None:
...
The Sender protocol says that an object should provide a send() method that accepts a string and returns None.
A class does not need to inherit from Sender to satisfy this protocol.
class EmailSender:
def send(self, message: str) -> None:
print(f"Sending email: {message}")
class SMSSender:
def send(self, message: str) -> None:
print(f"Sending SMS: {message}")
Both EmailSender and SMSSender provide the required send() method. Therefore, from a structural typing perspective, both can satisfy the Sender protocol.
What Is Structural Subtyping?
Structural subtyping determines compatibility based on the structure or behavior of an object.
Consider two classes:
class Dog:
def speak(self):
print("Woof")
class Robot:
def speak(self):
print("Beep")
These classes are unrelated. Robot does not inherit from Dog, and Dog does not inherit from Robot. However, both provide a speak() method.
If a function only needs an object that can speak(), it does not necessarily need to know whether the object is a dog or a robot.
This idea can be expressed using a protocol:
from typing import Protocol
class Speaker(Protocol):
def speak(self) -> None:
...
Now any class providing the required speak() method can structurally satisfy Speaker.
Structural Subtyping vs Nominal Subtyping
There are two important approaches to determining whether one type is compatible with another.
Nominal subtyping is based on explicit inheritance or declared relationships.
class Animal:
pass
class Dog(Animal):
pass
Here, Dog is an Animal because it explicitly inherits from Animal.
Structural subtyping does not require this explicit inheritance. It looks at whether the object provides the required structure.
from typing import Protocol
class Drawable(Protocol):
def draw(self) -> None:
...
A class such as this can satisfy the protocol without inheriting from it:
class Circle:
def draw(self) -> None:
print("Drawing a circle")
The important difference is that nominal typing asks, "What class does this object belong to?" Structural typing asks, "Does this object provide the required behavior?"
Creating a Protocol
A protocol is created by inheriting from Protocol.
from typing import Protocol
class Printable(Protocol):
def print_data(self) -> None:
...
The method body is generally represented with ... because the protocol describes an expected interface rather than providing the implementation.
A class can then provide its own implementation:
class Report:
def print_data(self) -> None:
print("Printing report")
Another completely unrelated class can also satisfy the same protocol:
class Invoice:
def print_data(self) -> None:
print("Printing invoice")
Both classes provide the behavior required by Printable.
Using Protocols in Functions
Protocols become particularly useful when specifying what a function expects.
from typing import Protocol
class Writer(Protocol):
def write(self, text: str) -> None:
...
def save_data(writer: Writer, data: str) -> None:
writer.write(data)
The save_data() function does not need to know the exact class of writer. It only needs an object that provides a compatible write() method.
For example:
class FileWriter:
def write(self, text: str) -> None:
print(f"Writing to file: {text}")
class ConsoleWriter:
def write(self, text: str) -> None:
print(f"Console: {text}")
Both can be passed to the function:
save_data(FileWriter(), "Hello")
save_data(ConsoleWriter(), "Hello")
This makes the function more flexible because it depends on behavior rather than a specific implementation.
Protocols With Attributes
Protocols can define attributes as well as methods.
from typing import Protocol
class User(Protocol):
name: str
age: int
A class providing compatible attributes can satisfy the protocol:
class Student:
def __init__(self, name: str, age: int):
self.name = name
self.age = age
The Student class does not need to inherit from User.
The protocol simply describes the required structure.
Protocols With Multiple Methods
A protocol can contain several required methods.
from typing import Protocol
class Storage(Protocol):
def save(self, data: str) -> None:
...
def load(self) -> str:
...
Any class intended to satisfy this protocol must provide both methods with compatible signatures.
class DatabaseStorage:
def save(self, data: str) -> None:
print("Saving data to database")
def load(self) -> str:
return "Database data"
The protocol allows the rest of the application to work with the storage behavior without being tightly connected to DatabaseStorage.
Runtime Checking With Protocols
Protocols primarily provide benefits for static type checking. By default, a protocol is not necessarily intended to be used with isinstance().
If runtime checking is required, @runtime_checkable can be used.
from typing import Protocol, runtime_checkable
@runtime_checkable
class Speaker(Protocol):
def speak(self) -> None:
...
Now an object can be checked at runtime:
class Dog:
def speak(self) -> None:
print("Woof")
dog = Dog()
print(isinstance(dog, Speaker))
The result can be True because Dog provides the required speak() member.
However, runtime protocol checks are limited. They primarily check whether required attributes or methods exist; they do not perform the full static type analysis that tools such as type checkers can provide.
Static Type Checking
Protocols are particularly valuable with static type checking tools such as mypy or Pyright.
Consider:
from typing import Protocol
class Reader(Protocol):
def read(self) -> str:
...
def process(reader: Reader):
data = reader.read()
print(data)
If a class does not provide the required method:
class IncorrectReader:
def write(self, data: str) -> None:
print(data)
A static type checker can identify that IncorrectReader does not satisfy the Reader protocol because it lacks the required read() method.
This allows many compatibility problems to be detected before the program runs.
Explicit Protocol Inheritance
Although structural subtyping does not require a class to inherit from a protocol, a class can explicitly inherit from one when desired.
from typing import Protocol
class PaymentProcessor(Protocol):
def pay(self, amount: float) -> None:
...
class CreditCardProcessor(PaymentProcessor):
def pay(self, amount: float) -> None:
print(f"Paid {amount}")
This can make the relationship clearer to developers.
However, explicit inheritance is not necessary for structural compatibility. Another class can provide the same pay() method without inheriting from PaymentProcessor.
Why Protocols Are Useful
Protocols help reduce unnecessary dependencies between components.
Suppose an application has a function that sends notifications. Instead of requiring a specific notification class, the function can depend on a protocol:
from typing import Protocol
class Notifier(Protocol):
def notify(self, message: str) -> None:
...
def send_notification(notifier: Notifier, message: str) -> None:
notifier.notify(message)
Email, SMS, push notification, and other notification classes can independently implement notify().
This approach provides several advantages:
-
Loose coupling
Code depends on required behavior instead of concrete classes. -
Flexibility
Multiple unrelated classes can satisfy the same protocol. -
Better maintainability
Components can be changed without modifying every part of the application. -
Improved testability
Simple test objects can be created that provide only the required behavior. -
Clear interfaces
Protocols document what an object is expected to provide. -
Better static analysis
Type checkers can detect incompatible implementations during development.
Protocols and Duck Typing
Protocols are closely related to Python's concept of duck typing.
Duck typing is based on the idea that an object is suitable when it provides the behavior required by the code.
For example:
def make_sound(animal):
animal.speak()
The function does not necessarily care about the object's class. It simply expects the object to have a speak() method.
Protocols formalize this idea for type checking:
from typing import Protocol
class Speaker(Protocol):
def speak(self) -> None:
...
Therefore, protocols can be viewed as a way of describing duck-typed interfaces in a form that static type checkers can understand.
Practical Example
Consider a document-processing application.
Different document classes may provide a save() method:
class PDFDocument:
def save(self, filename: str) -> None:
print(f"Saving PDF as {filename}")
class WordDocument:
def save(self, filename: str) -> None:
print(f"Saving Word document as {filename}")
Instead of making a function depend on either PDFDocument or WordDocument, define a protocol:
from typing import Protocol
class Document(Protocol):
def save(self, filename: str) -> None:
...
Then:
def save_document(document: Document, filename: str) -> None:
document.save(filename)
Both document classes can be passed to save_document() because they provide the required save() behavior.
This design becomes especially useful when new document types are introduced. A new class can participate simply by implementing the required interface.
Protocols With Properties
Protocols can also describe properties.
from typing import Protocol
class Product(Protocol):
@property
def price(self) -> float:
...
A class can satisfy the protocol by providing a compatible property:
class Book:
def __init__(self, price: float):
self._price = price
@property
def price(self) -> float:
return self._price
The protocol focuses on the externally available behavior rather than how the price is internally stored.
Advantages and Limitations
Protocols are powerful, but they should be used appropriately.
Advantages include flexible interfaces, reduced coupling, easier testing, improved type checking, and support for Python's duck-typing philosophy.
There are also limitations. Protocols primarily benefit developers when static type checking is used. Runtime checks are more limited than static analysis. Very large or complicated protocols can also become difficult to understand and maintain.
A protocol should therefore describe a meaningful and focused interface rather than attempting to define every possible capability of an object.
Conclusion
Python protocols and structural subtyping provide a flexible way to describe object behavior without requiring explicit inheritance. A protocol specifies the methods and attributes an object should provide, while structural subtyping determines compatibility based on those requirements.
The major idea is simple: an object can be considered compatible because of what it can do, not only because of the class it belongs to.
This approach works naturally with Python's duck-typing philosophy while adding stronger support for static type checking. Protocols are particularly useful in large applications, reusable libraries, plugin systems, testing, and software architectures where different implementations need to follow the same behavioral interface.