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
Note: Only use 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)