Skip to main content
Advertisement

Protocol: Structural Subtyping

Protocol (Python 3.8+) defines types by structure (methods/attributes) alone, without explicit inheritance. It applies static type hints to duck typing, allowing type checkers to verify whether a class implements a specific interface.


Protocol vs ABC

from abc import ABC, abstractmethod
from typing import Protocol


# ABC approach: explicit inheritance required
class Drawable(ABC):
@abstractmethod
def draw(self) -> str: ...


class Circle(Drawable): # Must inherit Drawable
def draw(self) -> str:
return "Drawing circle"


# Protocol approach: no inheritance needed, just matching structure
class Renderable(Protocol):
def render(self) -> str: ...


class Square: # No need to inherit Renderable!
def render(self) -> str:
return "Rendering square"


class Triangle:
def render(self) -> str:
return "Rendering triangle"


def display(item: Renderable) -> None:
"""Works with anything satisfying the Renderable Protocol"""
print(item.render())


display(Square()) # Rendering square
display(Triangle()) # Rendering triangle
# display("hello") # mypy reports error: str has no render()

Using typing.Protocol

from typing import Protocol, runtime_checkable


class Serializable(Protocol):
"""Protocol for serializable objects"""

def to_dict(self) -> dict:
"""Convert to dictionary"""
...

def to_json(self) -> str:
"""Convert to JSON string"""
...

@classmethod
def from_dict(cls, data: dict) -> "Serializable":
"""Create object from dictionary"""
...


class User:
def __init__(self, id: int, name: str, email: str):
self.id = id
self.name = name
self.email = email

def to_dict(self) -> dict:
return {"id": self.id, "name": self.name, "email": self.email}

def to_json(self) -> str:
import json
return json.dumps(self.to_dict(), ensure_ascii=False)

@classmethod
def from_dict(cls, data: dict) -> "User":
return cls(data["id"], data["name"], data["email"])


class Product:
def __init__(self, sku: str, name: str, price: float):
self.sku = sku
self.name = name
self.price = price

def to_dict(self) -> dict:
return {"sku": self.sku, "name": self.name, "price": self.price}

def to_json(self) -> str:
import json
return json.dumps(self.to_dict(), ensure_ascii=False)

@classmethod
def from_dict(cls, data: dict) -> "Product":
return cls(data["sku"], data["name"], data["price"])


def save_to_cache(item: Serializable, key: str) -> None:
"""Anything satisfying the Serializable Protocol can be saved"""
data = item.to_json()
print(f"cache[{key}] = {data}")


# Both User and Product satisfy the Serializable Protocol
save_to_cache(User(1, "Alice", "alice@example.com"), "user:1")
save_to_cache(Product("P001", "Python Book", 35000), "product:P001")

runtime_checkable: Support for isinstance()

By default, Protocol is only used for static type checking. Adding the @runtime_checkable decorator enables runtime isinstance() checks.

from typing import Protocol, runtime_checkable


@runtime_checkable
class Comparable(Protocol):
def __lt__(self, other) -> bool: ...
def __eq__(self, other) -> bool: ...


class Point:
def __init__(self, x: float, y: float):
self.x = x
self.y = y

def __lt__(self, other: "Point") -> bool:
return (self.x ** 2 + self.y ** 2) < (other.x ** 2 + other.y ** 2)

def __eq__(self, other: object) -> bool:
if not isinstance(other, Point):
return NotImplemented
return self.x == other.x and self.y == other.y


class Score:
def __init__(self, value: int):
self.value = value

def __lt__(self, other: "Score") -> bool:
return self.value < other.value

def __eq__(self, other: object) -> bool:
return isinstance(other, Score) and self.value == other.value


p = Point(3, 4)
s = Score(95)

# runtime_checkable enables isinstance()
print(isinstance(p, Comparable)) # True
print(isinstance(s, Comparable)) # True
print(isinstance("hello", Comparable)) # False
print(isinstance(42, Comparable)) # True (int supports comparison)

# Note: runtime_checkable only checks method existence, not signatures

Practical Protocol Patterns

Drawable Protocol

from typing import Protocol


class Drawable(Protocol):
"""Objects that can be drawn"""
x: float
y: float

def draw(self, canvas: "Canvas") -> None: ...
def bounding_box(self) -> tuple[float, float, float, float]: ...


class Canvas:
def __init__(self, width: int, height: int):
self.width = width
self.height = height
self._objects: list = []

def add(self, obj: Drawable) -> None:
self._objects.append(obj)

def render_all(self) -> None:
for obj in self._objects:
obj.draw(self)


class CircleShape:
def __init__(self, x: float, y: float, radius: float):
self.x = x
self.y = y
self.radius = radius

def draw(self, canvas: Canvas) -> None:
print(f"Drawing circle on canvas ({canvas.width}x{canvas.height}): "
f"center ({self.x}, {self.y}), radius={self.radius}")

def bounding_box(self) -> tuple[float, float, float, float]:
return (self.x - self.radius, self.y - self.radius,
self.x + self.radius, self.y + self.radius)


class RectShape:
def __init__(self, x: float, y: float, width: float, height: float):
self.x = x
self.y = y
self.width = width
self.height = height

def draw(self, canvas: Canvas) -> None:
print(f"Drawing rect on canvas: ({self.x}, {self.y}), {self.width}x{self.height}")

def bounding_box(self) -> tuple[float, float, float, float]:
return (self.x, self.y, self.x + self.width, self.y + self.height)


canvas = Canvas(800, 600)
canvas.add(CircleShape(100, 100, 50))
canvas.add(RectShape(200, 200, 100, 80))
canvas.render_all()

Serializable Protocol

from typing import Protocol, TypeVar
import json

T = TypeVar("T", bound="JsonSerializable")


class JsonSerializable(Protocol):
def to_json(self) -> str: ...

@classmethod
def from_json(cls: type[T], json_str: str) -> T: ...


class Config:
def __init__(self, host: str, port: int, debug: bool):
self.host = host
self.port = port
self.debug = debug

def to_json(self) -> str:
return json.dumps({"host": self.host, "port": self.port, "debug": self.debug})

@classmethod
def from_json(cls, json_str: str) -> "Config":
data = json.loads(json_str)
return cls(**data)


class UserSettings:
def __init__(self, theme: str, language: str, notifications: bool):
self.theme = theme
self.language = language
self.notifications = notifications

def to_json(self) -> str:
return json.dumps(vars(self))

@classmethod
def from_json(cls, json_str: str) -> "UserSettings":
return cls(**json.loads(json_str))


def save_settings(settings: JsonSerializable, filename: str) -> None:
json_str = settings.to_json()
print(f"Saving to {filename}: {json_str}")


def load_and_print(cls: type, json_str: str) -> None:
obj = cls.from_json(json_str)
print(f"Loaded: {vars(obj)}")


cfg = Config("localhost", 8080, True)
user_settings = UserSettings("dark", "en", True)

save_settings(cfg, "config.json")
save_settings(user_settings, "user.json")

Comparable Protocol

from typing import Protocol, TypeVar

T = TypeVar("T")


class Comparable(Protocol):
def __lt__(self, other: "Comparable") -> bool: ...
def __le__(self, other: "Comparable") -> bool: ...
def __gt__(self, other: "Comparable") -> bool: ...
def __ge__(self, other: "Comparable") -> bool: ...
def __eq__(self, other: object) -> bool: ...


def find_min(items: list[T]) -> T:
"""Works with any list of types satisfying the Comparable Protocol"""
if not items:
raise ValueError("Empty list")
result = items[0]
for item in items[1:]:
if item < result: # type: ignore
result = item
return result


def find_max(items: list[T]) -> T:
if not items:
raise ValueError("Empty list")
result = items[0]
for item in items[1:]:
if item > result: # type: ignore
result = item
return result


# Use the same function for various types
print(find_min([3, 1, 4, 1, 5])) # 1
print(find_max(["banana", "apple"])) # banana
print(find_min([3.14, 2.71, 1.41])) # 1.41

When to Use Protocol vs ABC

When to use Protocol:
- When you want to include third-party classes or built-in types in an interface
- When you want to express duck typing with type hints without explicit inheritance
- When there is no class hierarchy or it is unnecessary
- When typing.Protocol is more flexible and loosely coupled

When to use ABC:
- When you want to put concrete implementations (shared logic) in the base class
- When you frequently need isinstance() type checks at runtime
- When the team/codebase prefers explicit class hierarchies
- When you want to force implementations with @abstractmethod
# ABC is suitable: sharing common implementations
from abc import ABC, abstractmethod


class BaseRepository(ABC):
def __init__(self, db_url: str):
self._db_url = db_url
self._connection = None

def connect(self) -> None: # Common implementation
print(f"Connecting to {self._db_url}")
self._connection = f"conn:{self._db_url}"

@abstractmethod
def find_by_id(self, id: int) -> dict | None: ...

@abstractmethod
def save(self, data: dict) -> bool: ...


# Protocol is suitable: integrating with external classes
class SupportsRead(Protocol):
def read(self, n: int = -1) -> str: ...


def count_words(stream: SupportsRead) -> int:
"""Works with files, StringIO, sockets — anything with read()"""
return len(stream.read().split())

Expert Tips

1. Providing Default Implementations in Protocol Methods

from typing import Protocol, runtime_checkable


@runtime_checkable
class Loggable(Protocol):
def get_log_level(self) -> str: ...

def log(self, message: str) -> None:
"""Can also have default implementation (but not a required method of the Protocol)"""
print(f"[{self.get_log_level()}] {message}")

2. Generic Protocol

from typing import Protocol, TypeVar

T_co = TypeVar("T_co", covariant=True)


class Iterable(Protocol[T_co]):
def __iter__(self) -> "Iterator[T_co]": ...


class Iterator(Protocol[T_co]):
def __next__(self) -> T_co: ...
def __iter__(self) -> "Iterator[T_co]": ...

3. Composing More Specific Protocols via Protocol Inheritance

from typing import Protocol


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


class Writable(Protocol):
def write(self, data: str) -> int: ...


class ReadWritable(Readable, Writable, Protocol):
"""Both readable and writable"""
...


def copy(src: Readable, dst: Writable) -> None:
dst.write(src.read())

Summary

FeatureProtocolABC
Inheritance requiredNo (structural subtyping)Yes (nominal subtyping)
Runtime isinstance()Requires @runtime_checkableSupported by default
Sharing common implementationLimitedFully supported
CouplingLowHigh
Third-party class integrationEasyDifficult
Primary use caseType hints, expressing duck typingEnforcing interfaces + shared logic

Protocol elegantly combines Python's duck typing philosophy with the static type system.

Advertisement