Protocols and abstract base classes

Structural (Protocol) vs nominal (ABC) typing and when each one buys you real safety.

๐Ÿงฑ Module 2 7 min read Not started

Why this matters

Python's "duck typing" โ€” "if it walks like a duck and quacks like a duck, it is a duck" โ€” is its biggest superpower and biggest footgun. Protocols (PEP 544) give you the duck typing flexibility and static type checking. Knowing when to reach for Protocol, ABC, or just type-hinted concrete classes is what distinguishes idiomatic modern Python.

Learning objectives

  1. Use duck typing without losing type safety.
  2. Define Protocols for structural typing.
  3. Use @runtime_checkable for isinstance on protocols.
  4. Pick between Protocol, ABC, and concrete classes.
  5. Apply the standard "iterable / iterator / sequence" protocols.

1. Duck typing โ€” the cultural default

python
def total_lengths(items):
    return sum(len(x) for x in items)

total_lengths(["a", "bb", "ccc"])          # 6
total_lengths([{"a": 1}, {"a": 1, "b": 2}])  # 3
total_lengths([(1,), (1, 2), (1, 2, 3)])   # 6

The function works with anything that has a __len__ and is iterable. No isinstance checks. No declared interface. Just hopes.

This is structural typing: you depend on shape, not lineage.

The problem: nothing catches a wrong caller until runtime โ€” and Python tools couldn't type-check this until PEP 544.


2. Nominal vs structural typing

Nominal (Java, C#): a type checker says "yes" only if you declared inheritance/implementation.

Structural (TypeScript, Go interfaces, Python Protocol): the type checker says "yes" if the object has the right shape.

Python uses both:

  • Nominal via ABCs (isinstance(x, MutableMapping)).
  • Structural via Protocols (x has keys() and __getitem__).

3. Protocol โ€” the right way to type duck-typed code

python
from typing import Protocol

class SupportsLen(Protocol):
    def __len__(self) -> int: ...

def total_lengths(items: list[SupportsLen]) -> int:
    return sum(len(x) for x in items)

What this means:

  • mypy / pyright accept anything with a __len__(self) -> int method.
  • No isinstance(x, SupportsLen) at runtime by default.
  • No need for callers to subclass SupportsLen.

The ... body means "no implementation; this is just a shape."

Real-world example

python
from typing import Protocol

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

def sync(source: Repository, dest: Repository, ids: list[int]) -> None:
    for i in ids:
        item = source.get(i)
        if item is not None:
            dest.save(item)

Any class with get + save matching those signatures works โ€” InMemoryRepo, SQLRepo, S3Repo โ€” without subclassing.


4. runtime_checkable โ€” isinstance on protocols

By default, Protocols are static only. To use them with isinstance, mark @runtime_checkable:

python
from typing import Protocol, runtime_checkable

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

isinstance(open("x.txt"), Closable)   # True

Caveats:

  • Only checks method existence, not signatures.
  • Slower than nominal isinstance.
  • Use sparingly; static checks are usually enough.

5. ABCs vs Protocols โ€” when to use which

Use ABC when...Use Protocol when...
You own all implementationsYou want third parties to "fit in" without subclassing
You want to share base behaviour, not just signaturesYou only care about shape
Plugin systems where registration is explicitStandard-library style adapters (Iterable, Sized)
You need mixin style code reuseModern type-hint-only contracts

In 2026, prefer Protocol for new public APIs. ABCs remain for inheritance hierarchies where you genuinely share code.

python
# Concrete impl + Protocol
class S3Repository:
    def get(self, id): ...
    def save(self, item): ...

def sync(src: Repository, dst: Repository): ...   # Repository is a Protocol

sync(S3Repository(), MemoryRepo())   # works; no inheritance

6. Generic Protocols

python
from typing import Protocol, TypeVar

T = TypeVar("T")

class Container(Protocol[T]):
    def add(self, item: T) -> None: ...
    def pop(self) -> T: ...

def drain(c: Container[int]) -> list[int]:
    out = []
    try:
        while True:
            out.append(c.pop())
    except IndexError:
        return out

Generic protocols are powerful. Phase 3.5 covers TypeVar, Generic, ParamSpec in depth.


7. collections.abc protocols you'll often type against

UseType
Any for-loopableIterable[T]
Has __next__Iterator[T]
Has len()Sized
in worksContainer[T]
Indexable + sizedSequence[T]
Mutable list-likeMutableSequence[T]
dict-likeMapping[K, V], MutableMapping[K, V]
CallableCallable[[A, B], R]

These come from collections.abc and double as both ABCs and structural protocols:

python
from collections.abc import Iterable, Mapping

def sum_values(m: Mapping[str, int]) -> int:
    return sum(m.values())

sum_values({"a": 1, "b": 2})            # 3
sum_values(MappingProxyType({...}))     # also fine

8. The classic Python protocols

Iterator protocol

python
class CountDown:
    def __init__(self, n: int): self.n = n

    def __iter__(self):
        return self                  # iterator is its own iterable

    def __next__(self):
        if self.n <= 0:
            raise StopIteration
        self.n -= 1
        return self.n + 1

list(CountDown(3))                   # [3, 2, 1]

In practice, you almost always use generators (Phase 3.2) instead of __iter__/__next__.

Context manager protocol

python
class Tx:
    def __enter__(self):
        self.connection = open_db()
        return self.connection
    def __exit__(self, exc_type, exc, tb):
        if exc:
            self.connection.rollback()
        else:
            self.connection.commit()
        self.connection.close()
        return False                  # don't suppress exception

with Tx() as conn:
    conn.execute(...)

(Phase 3.3 deep dive.)

Callable protocol

Any object with __call__ is callable:

python
class Adder:
    def __init__(self, by: int): self.by = by
    def __call__(self, x: int) -> int: return x + self.by

add5 = Adder(5)
add5(3)                              # 8
callable(add5)                       # True

Useful for stateful "function-like" objects (counters, accumulators, throttlers).

Hash + equality protocol

(Recap Lesson 2.1.) Implement together. Equal objects must hash equal.

Comparison protocol

__lt__, __le__, __gt__, __ge__. functools.total_ordering fills the gaps.


9. Worked example: storage backends

python
from typing import Protocol, runtime_checkable

@runtime_checkable
class KeyValueStore(Protocol):
    def get(self, key: str) -> bytes | None: ...
    def put(self, key: str, value: bytes) -> None: ...
    def delete(self, key: str) -> None: ...

# Three implementations โ€” no shared base class
class MemoryStore:
    def __init__(self) -> None: self._d: dict[str, bytes] = {}
    def get(self, k): return self._d.get(k)
    def put(self, k, v): self._d[k] = v
    def delete(self, k): self._d.pop(k, None)

class FileStore:
    def __init__(self, path): self.path = Path(path); self.path.mkdir(exist_ok=True)
    def get(self, k): p = self.path / k; return p.read_bytes() if p.exists() else None
    def put(self, k, v): (self.path / k).write_bytes(v)
    def delete(self, k): (self.path / k).unlink(missing_ok=True)

class S3Store:
    ...                              # boto3 wrapper

def cache_get_or_compute(store: KeyValueStore, key: str, compute) -> bytes:
    v = store.get(key)
    if v is None:
        v = compute()
        store.put(key, v)
    return v

cache_get_or_compute works with any of the three โ€” and mypy verifies the call site, even though no class inherits from KeyValueStore.


Hands-on lab (1.5 hours)

  1. Define a Comparable Protocol with __lt__; write a generic sorted2(xs: list[Comparable]) -> list.
  2. Build three classes implementing a Notifier Protocol; write a notify_all(notifiers, msg).
  3. Add @runtime_checkable to one of your protocols; verify isinstance works on the implementations.
  4. Find a place in your code where you isinstance(x, MyBase); refactor to Protocol-typed parameters.
  5. Read the collections.abc source for Sequence; list the abstract methods and the ones it provides for free.
  6. Bonus: define a generic Cache[K, V] Protocol; implement an in-memory and a disk version.

Common pitfalls

  1. Adding methods to a Protocol after callers exist โ†’ silently breaks them.
  2. isinstance(x, Protocol) without @runtime_checkable โ†’ TypeError.
  3. Overusing Protocols for one-off internal types; just use a concrete class.
  4. Treating Protocol as a base class for code reuse โ€” it's for typing only; use ABCs for shared implementation.
  5. Forgetting that runtime_checkable doesn't check argument types, only attribute presence.

Self-check

  1. Define structural vs nominal typing.
  2. What does Protocol give you that ABC doesn't?
  3. When use @runtime_checkable?
  4. State three protocols from collections.abc.
  5. Why is __call__ useful?

References

  • PEP 544 โ€” Protocols: Structural subtyping.
  • PEP 3119 โ€” Abstract Base Classes.
  • Fluent Python, Ramalho โ€” Chapter 13.
  • mypy docs on Protocols: https://mypy.readthedocs.io/en/stable/protocols.html.
  • ลukasz Langa, "Python's Type System" PyCon talk.

Sign in to save your progress and earn badges.