Protocols & Structural Subtyping

advanced Protocol structural-subtyping typing runtime_checkable interface

Python has always had duck typing — “if it walks like a duck and quacks like a duck, it’s a duck.” But duck typing happens at runtime. What if we want the type checker to verify our ducks before the code runs? That’s what typing.Protocol gives us: static duck typing.

Nominal vs Structural Subtyping

There are two ways a language can decide if a type “fits”:

Nominal subtyping: “You ARE this type because you explicitly said so” (by inheriting). Java interfaces, Python ABCs — the class must declare its lineage.

Structural subtyping: “You ARE this type because you have the right shape” (the right methods/attributes). No inheritance needed. If it has a draw() method, it’s drawable.

In simple language, nominal is about who you are, structural is about what you can do.

Defining a Protocol

A Protocol is just a class that declares what methods or attributes something should have. Classes don’t need to inherit from it — they just need to match the shape.

from typing import Protocol

class Drawable(Protocol):
    def draw(self) -> str: ...  # just the signature, no body needed

class Circle:
    def draw(self) -> str:  # matches Drawable — no inheritance!
        return "Drawing circle"

class Square:
    def draw(self) -> str:
        return "Drawing square"

def render(shape: Drawable) -> None:  # type checker validates this
    print(shape.draw())

render(Circle())  # works — Circle matches the Protocol
render(Square())  # works — Square matches too

Neither Circle nor Square inherits from Drawable. They just happen to have a draw() method with the right signature. The type checker (mypy, pyright) validates this statically.

Protocols with Attributes

Protocols can require attributes, not just methods:

from typing import Protocol

class Named(Protocol):
    name: str  # any class with a 'name: str' attribute matches

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

class Bot:
    name: str = "AutoBot"

def greet(entity: Named) -> str:
    return f"Hello, {entity.name}!"

print(greet(User("Manish")))  # Hello, Manish!
print(greet(Bot()))           # Hello, AutoBot!

@runtime_checkable

By default, Protocols only work at type-checking time. If we want to use isinstance() checks at runtime, we add @runtime_checkable:

from typing import Protocol, runtime_checkable

@runtime_checkable
class Closeable(Protocol):
    def close(self) -> None: ...

class FileHandler:
    def close(self) -> None:
        print("File closed")

f = FileHandler()
print(isinstance(f, Closeable))  # True — runtime check works!

A heads-up: runtime isinstance() checks with Protocols only verify that the methods exist. They don’t check argument types or return types. For full validation, we rely on the static type checker.

Protocol vs ABC

Both define “interfaces,” but they work very differently:

ABC (nominal): Classes must explicitly inherit from the ABC. Enforced at instantiation time. Can have concrete methods and state.

Protocol (structural): No inheritance needed. Enforced by the type checker, not at runtime (unless @runtime_checkable). Pure interface — no implementation.

from abc import ABC, abstractmethod
from typing import Protocol

# ABC approach — must inherit
class DrawableABC(ABC):
    @abstractmethod
    def draw(self) -> str: ...

class Circle(DrawableABC):  # MUST inherit
    def draw(self) -> str:
        return "circle"

# Protocol approach — no inheritance
class DrawableProto(Protocol):
    def draw(self) -> str: ...

class Square:  # no inheritance needed
    def draw(self) -> str:
        return "square"

When to use which:

  • ABC — when we own the hierarchy and want runtime enforcement (frameworks, plugin systems)
  • Protocol — when we want flexibility, especially with third-party code we can’t modify

Built-in Protocols

Python’s typing module comes with several Protocols we use all the time (even if we didn’t know they were Protocols):

from typing import Sized, Iterable, SupportsFloat, SupportsInt

# Any class with __len__ matches Sized
class Bag:
    def __len__(self) -> int:
        return 5

print(isinstance(Bag(), Sized))  # True

# SupportsFloat — anything with __float__
class Temp:
    def __float__(self) -> float:
        return 98.6

print(float(Temp()))  # 98.6

Other built-in protocols include SupportsAbs, SupportsRound, SupportsBytes, Hashable, and Reversible.

Practical Example: Repository Pattern

Protocols shine for dependency injection. Here’s a repository pattern where the service doesn’t care about the concrete storage implementation:

from typing import Protocol

class UserRepo(Protocol):
    def get(self, user_id: int) -> dict: ...
    def save(self, user: dict) -> None: ...

class PostgresRepo:  # no inheritance from UserRepo
    def get(self, user_id: int) -> dict:
        return {"id": user_id, "name": "from postgres"}

    def save(self, user: dict) -> None:
        print(f"Saved to Postgres: {user}")

class InMemoryRepo:  # great for testing
    def __init__(self):
        self.store = {}

    def get(self, user_id: int) -> dict:
        return self.store.get(user_id, {})

    def save(self, user: dict) -> None:
        self.store[user["id"]] = user
class UserService:
    def __init__(self, repo: UserRepo):  # accepts ANY matching repo
        self.repo = repo

    def get_user(self, user_id: int) -> dict:
        return self.repo.get(user_id)

# Production
service = UserService(PostgresRepo())

# Testing — swap in a fake, no mocking needed
test_service = UserService(InMemoryRepo())

Neither PostgresRepo nor InMemoryRepo knows about UserRepo. They just happen to have the right methods. The type checker validates everything, and we get full flexibility for testing.

Combining Multiple Protocols

We can compose Protocols to build complex interfaces from small pieces:

from typing import Protocol

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

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

class ReadWrite(Readable, Writable, Protocol):
    ...  # combines both

def process(stream: ReadWrite) -> None:
    data = stream.read()
    stream.write(data.upper())

This is Python’s version of interface segregation — small, focused protocols instead of one big interface.

In simple language, typing.Protocol is Python’s way of formalizing duck typing. Instead of forcing classes to inherit from an interface, we just describe the shape we expect and let the type checker verify it. It’s perfect for flexible code that works with any class that has the right methods — no inheritance required.