Protocols — Structural Subtyping and Duck Typing with Types

Python’s traditional approach to polymorphism is inheritance — a function that accepts an Animal can receive any subclass. But inheritance requires upfront design: you must know the hierarchy before writing the code. Protocols (introduced in Python 3.8) bring structural subtyping — a type satisfies a Protocol if it has the right methods and attributes, regardless of its inheritance chain. This is “duck typing with documentation”: the Protocol says “I need something that quacks like a duck” without requiring the duck to inherit from Duck. Protocols make dependency injection in FastAPI testable without creating elaborate class hierarchies.

Defining and Using Protocols

from typing import Protocol, runtime_checkable

# ── Define a Protocol — describe what methods/attributes a type must have ─────
class Serialisable(Protocol):
    """Any object with a to_dict() method satisfies this Protocol."""
    def to_dict(self) -> dict: ...

class Closeable(Protocol):
    """Any object with a close() method satisfies this Protocol."""
    def close(self) -> None: ...

# ── Concrete classes — no inheritance required ────────────────────────────────
class Post:
    def __init__(self, id: int, title: str):
        self.id    = id
        self.title = title

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

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

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

# ── Function accepts anything that satisfies the Protocol ─────────────────────
def export_to_json(obj: Serialisable) -> str:
    import json
    return json.dumps(obj.to_dict())

# Both work — neither inherits from Serialisable
print(export_to_json(Post(1, "Hello")))   # {"id": 1, "title": "Hello"}
print(export_to_json(User(1, "Alice")))   # {"id": 1, "name": "Alice"}

# mypy checks that both Post and User have to_dict() -> dict ✓
Note: A class satisfies a Protocol if it has all the methods and attributes defined in the Protocol with compatible types — there is no implements keyword or explicit inheritance required. The Protocol is checked by mypy (statically) or with isinstance() (at runtime, if the Protocol is decorated with @runtime_checkable). This is Python’s built-in duck typing made explicit and checkable.
Tip: Define Protocols for your FastAPI dependencies — database repositories, email senders, file storage adapters — rather than concrete classes. Your route handlers accept the Protocol type; in production you inject a real PostgreSQL repository; in tests you inject an in-memory mock. Neither the repository nor the mock needs to inherit from anything — they just need to have the right methods. This makes unit testing FastAPI route handlers trivial.
Warning: @runtime_checkable on a Protocol enables isinstance() checks, but they are incomplete — they only check that the required methods exist, not that they have the right signatures or that attribute types match. isinstance(obj, MyProtocol) returning True does not guarantee the object will work correctly — it just means the method names are present. Full correctness is only guaranteed by mypy’s static analysis.

Protocols for FastAPI Dependency Injection

from typing import Protocol
from fastapi import FastAPI, Depends

app = FastAPI()

# ── Define Protocol for the repository interface ───────────────────────────────
class PostRepository(Protocol):
    """Interface for post data access — any object with these methods qualifies."""
    def get_by_id(self, post_id: int) -> dict | None: ...
    def list_published(self, page: int, size: int) -> list[dict]: ...
    def create(self, data: dict) -> dict: ...
    def update(self, post_id: int, data: dict) -> dict | None: ...
    def delete(self, post_id: int) -> bool: ...

# ── Production implementation ──────────────────────────────────────────────────
class SQLAlchemyPostRepository:
    def __init__(self, db):
        self.db = db

    def get_by_id(self, post_id: int) -> dict | None:
        post = self.db.query(Post).filter(Post.id == post_id).first()
        return post.__dict__ if post else None

    def list_published(self, page: int, size: int) -> list[dict]:
        posts = (self.db.query(Post)
                 .filter(Post.published == True)
                 .offset((page - 1) * size)
                 .limit(size).all())
        return [p.__dict__ for p in posts]

    def create(self, data: dict) -> dict:
        post = Post(**data)
        self.db.add(post)
        self.db.commit()
        return post.__dict__

    def update(self, post_id: int, data: dict) -> dict | None:
        post = self.db.query(Post).get(post_id)
        if not post:
            return None
        for k, v in data.items():
            setattr(post, k, v)
        self.db.commit()
        return post.__dict__

    def delete(self, post_id: int) -> bool:
        post = self.db.query(Post).get(post_id)
        if not post:
            return False
        self.db.delete(post)
        self.db.commit()
        return True

# ── Test implementation (in-memory) ───────────────────────────────────────────
class InMemoryPostRepository:
    def __init__(self):
        self._store: dict[int, dict] = {}
        self._next_id = 1

    def get_by_id(self, post_id: int) -> dict | None:
        return self._store.get(post_id)

    def list_published(self, page: int, size: int) -> list[dict]:
        pub = [p for p in self._store.values() if p.get("published")]
        start = (page - 1) * size
        return pub[start:start + size]

    def create(self, data: dict) -> dict:
        post = {**data, "id": self._next_id}
        self._store[self._next_id] = post
        self._next_id += 1
        return post

    def update(self, post_id: int, data: dict) -> dict | None:
        if post_id not in self._store:
            return None
        self._store[post_id].update(data)
        return self._store[post_id]

    def delete(self, post_id: int) -> bool:
        return self._store.pop(post_id, None) is not None

# ── Route handler accepts the Protocol type ───────────────────────────────────
def get_repo(db = Depends(get_db)) -> PostRepository:
    return SQLAlchemyPostRepository(db)

@app.get("/posts/{post_id}")
def get_post(post_id: int, repo: PostRepository = Depends(get_repo)):
    post = repo.get_by_id(post_id)
    if post is None:
        raise HTTPException(404)
    return post

# ── Test — inject in-memory repo with no database needed ──────────────────────
def test_get_post():
    repo = InMemoryPostRepository()
    repo.create({"title": "Test", "published": True})
    post = repo.get_by_id(1)
    assert post["title"] == "Test"   # ✓ no database, no FastAPI app needed

runtime_checkable Protocols

from typing import Protocol, runtime_checkable

@runtime_checkable
class Drawable(Protocol):
    def draw(self) -> None: ...

class Circle:
    def draw(self): print("O")

class Square:
    def draw(self): print("□")

class Triangle:
    pass   # no draw() method

# isinstance() checks for method presence
print(isinstance(Circle(),   Drawable))   # True
print(isinstance(Square(),   Drawable))   # True
print(isinstance(Triangle(), Drawable))   # False

# Filter a list to only Drawable objects
shapes = [Circle(), Square(), Triangle(), Circle()]
drawables = [s for s in shapes if isinstance(s, Drawable)]
for d in drawables:
    d.draw()   # O, □, O

Common Mistakes

Mistake 1 — Using abstract base classes where a Protocol is cleaner

❌ Wrong — requires inheritance from abstract class:

from abc import ABC, abstractmethod
class Repository(ABC):
    @abstractmethod
    def get(self, id: int): ...

class MyRepo(Repository):   # MUST inherit — couples implementation to ABC
    def get(self, id: int): ...

✅ Better — Protocol: no inheritance needed:

class Repository(Protocol):
    def get(self, id: int) -> dict | None: ...

class MyRepo:   # satisfies Protocol without inheriting ✓
    def get(self, id: int) -> dict | None: ...

Mistake 2 — Expecting runtime_checkable to check method signatures

❌ Wrong — isinstance only checks method names, not types:

@runtime_checkable
class Repo(Protocol):
    def get(self, id: int) -> dict | None: ...

class Wrong:
    def get(self):   # wrong signature — but isinstance still returns True!
        return "broken"

isinstance(Wrong(), Repo)   # True — method name exists, signature not checked!

✅ Correct — use mypy for full signature verification.

Mistake 3 — Defining Protocols too narrowly

❌ Wrong — Protocol requires too many methods, limits useful implementations:

class EmailSender(Protocol):
    def send(self, to, subject, body): ...
    def send_html(self, to, subject, html_body): ...
    def send_attachment(self, to, subject, body, file_path): ...
    # Testing requires implementing ALL of these

✅ Correct — define narrow, focused Protocols:

class EmailSender(Protocol):
    def send(self, to: str, subject: str, body: str) -> None: ...
    # ✓ test double only needs to implement send()

Quick Reference

Concept Code
Define Protocol class MyProto(Protocol): def method(self): ...
Runtime checkable @runtime_checkable class MyProto(Protocol):
isinstance check isinstance(obj, MyProto) (needs @runtime_checkable)
Accept Protocol type def f(repo: PostRepository): ...
Satisfy without inheriting Just implement the required methods
FastAPI DI with Protocol Return concrete type from Depends(), type-hint with Protocol

🧠 Test Yourself

Why are Protocols preferable to abstract base classes for FastAPI dependency injection interfaces?