25  Interfaces, Protocols, and ABCs

NoteCore idea

Python has four typing approaches (duck, goose, static, runtime). ABCs define formal interfaces. Protocols define structural interfaces. Both are tools — use the right one for the job.

In this chapter you will learn to:

  1. Place every type-checking style into the four-approach map.
  2. Use duck typing to satisfy a built-in protocol without inheritance.
  3. Register a virtual subclass of an ABC, and define your own ABC with abstract methods.
  4. Use typing.Protocol for structural typing that the static checker understands.
  5. Choose between an ABC (runtime + partial implementations) and a Protocol (static, structural).

25.1 The typing map

Python supports four typing styles, and they coexist in the same codebase:

Approach Checked when Mechanism
Duck typing runtime EAFP — just call the method
Goose typing runtime isinstance(x, ABC)
Static typing at type-check mypy, pyright
Runtime protocol runtime isinstance(x, Protocol) (with @runtime_checkable)

“EAFP” is the Pythonic acronym for Easier to Ask Forgiveness than Permission: try the operation, catch the exception if it fails. Its opposite is LBYL (Look Before You Leap) — if hasattr(x, 'method'): x.method(). Python’s data model favours EAFP because methods do their own type-checking and raise specific exceptions; the LBYL form is verbose and racy.

The right choice depends on who checks the contract and when. We’ll walk through the four in order.

flowchart TB
  Q["Need to constrain a value's shape?"]
  Q --> Duck["Duck typing<br/>just call the method<br/>no check"]
  Q --> Goose["Goose typing<br/>isinstance(x, ABC)<br/>runtime"]
  Q --> Static["Static typing<br/>mypy / pyright<br/>at type-check time"]
  Q --> Runtime["Runtime Protocol<br/>isinstance(x, P)<br/>P decorated @runtime_checkable"]

25.2 Duck typing: Python digs sequences

Duck typing is the original Python style: don’t ask what something is; try to use it. The interesting twist is that the standard-library ABCs are written so that anything implementing the right methods passes isinstance automatically — no inheritance, no registration:

from collections import abc

class Struggle:
    def __len__(self):
        return 23

isinstance(Struggle(), abc.Sized)
True

Walking through what happened:

  • Struggle never inherited from Sized. There is no class Struggle(Sized): anywhere — the two classes have no relationship in the source.
  • Sized defines a class method called __subclasshook__ that isinstance consults as a fallback. It checks whether the candidate has a __len__ method and answers “yes” if it does.
  • So isinstance(Struggle(), abc.Sized) returns True purely on the basis of method shape.

The general pattern: this is duck typing made formal — the standard library calls it “structural subtyping by special method.” You get the runtime check without forcing the candidate class to know anything about Sized.

25.3 Monkey patching to fit a protocol

Sometimes a class is almost compatible with a protocol but missing one method. You can patch it at runtime — gingerly. The classic example: random.shuffle requires __len__ and __setitem__, but a FrenchDeck that only supports reading doesn’t have __setitem__.

import collections, random

Card = collections.namedtuple("Card", "rank suit")

class FrenchDeck:
    ranks = [str(n) for n in range(2, 11)] + list("JQKA")
    suits = "spades diamonds clubs hearts".split()
    def __init__(self):
        self._cards = [Card(r, s) for s in self.suits for r in self.ranks]
    def __len__(self):
        return len(self._cards)
    def __getitem__(self, pos):
        return self._cards[pos]

deck = FrenchDeck()

def setitem(deck, position, card):
    deck._cards[position] = card

FrenchDeck.__setitem__ = setitem
random.shuffle(deck)
deck[0]
Card(rank='Q', suit='hearts')

Walking through the patch:

  • setitem is a plain function with the same signature as __setitem__ would have on the class — (self, position, card), except we name the first argument deck for clarity.
  • FrenchDeck.__setitem__ = setitem writes the function directly onto the class. From this assignment forward, every FrenchDeck instance and the class itself appear to have __setitem__. Python’s attribute lookup on deck[0] = card walks deck.__class__ and finds the patched method.
  • random.shuffle(deck) now succeeds because the protocol it requires (__len__, __getitem__, __setitem__) is fully present.

The general rule: monkey patching is a last resort. It modifies the class globally, which is invisible to anyone reading the original source. Use it for one-off scripts or test fixtures, not for library code.

25.4 Goose typing with ABCs

Goose typing (Alex Martelli’s name) is duck typing with a stamp of approval — the class explicitly says “I implement this protocol” by inheriting from an ABC. The ABC mechanism gives you two things: abstract methods (which subclasses must implement) and concrete mixin methods (which subclasses get for free).

import abc

class Tombola(abc.ABC):
    @abc.abstractmethod
    def load(self, iterable):
        """Add items from an iterable."""

    @abc.abstractmethod
    def pick(self):
        """Remove an item at random; raise LookupError if empty."""

    def loaded(self):
        return bool(self.inspect())

    def inspect(self):
        items = []
        while True:
            try:
                items.append(self.pick())
            except LookupError:
                break
        self.load(items)
        return tuple(sorted(items))

class BingoCage(Tombola):
    def __init__(self, items):
        self._items = list(items)
        random.shuffle(self._items)

    def load(self, items):
        self._items.extend(items)

    def pick(self):
        try:
            return self._items.pop()
        except IndexError:
            raise LookupError("pick from empty BingoCage")

bingo = BingoCage(range(3))
bingo.loaded(), sorted(bingo.inspect())
(True, [0, 1, 2])

Walking through the contract:

  • class Tombola(abc.ABC): declares an abstract base. Inheriting from abc.ABC is what makes @abstractmethod decorations actually enforced — without it the decorator is just metadata.
  • @abc.abstractmethod on load and pick marks them as required. Subclasses must override both, or instantiation fails.
  • loaded and inspect have bodies. They’re concrete methods on the abstract class — what’s sometimes called a “mixin” — and subclasses inherit them for free. inspect is interesting: it implements its own work in terms of the abstract pick and load, so any concrete Tombola automatically gets a working inspect.
  • BingoCage(Tombola) implements load and pick. Because both abstract methods are overridden, Tombola’s tracking marks the subclass concrete and instantiation succeeds.

The general pattern: an ABC is a contract. Abstract methods declare what the subclass owes; concrete methods declare what the subclass gets in return. If we’d forgotten one of the abstract methods:

class Half(Tombola):
    def load(self, items): pass
Half()
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[4], line 3
      1 class Half(Tombola):
      2     def load(self, items): pass
----> 3 Half()

TypeError: Can't instantiate abstract class Half without an implementation for abstract method 'pick'

Trying to instantiate raises TypeError. The ABC enforces the contract.

ABCs also support virtual subclasses — declaring an unrelated class as a subclass without inheritance. The standard library pre-registers tuple and list as Sequence this way. You can do the same for a class you can’t (or don’t want to) modify the bases of:

from collections.abc import Sequence

@Sequence.register
class FakeSeq:
    def __len__(self): return 0
    def __getitem__(self, i): raise IndexError

issubclass(FakeSeq, Sequence), issubclass(tuple, Sequence)
(True, True)

Walking through the registration:

  • @Sequence.register is equivalent to Sequence.register(FakeSeq) — the class becomes a “virtual subclass” of Sequence. There’s no MRO change, no inheritance — only isinstance and issubclass start saying yes.
  • FakeSeq doesn’t get any of Sequence’s mixin methods (__contains__, index, count). Those are inherited via real subclassing, not via registration. We’re only buying the isinstance answer.
  • issubclass(tuple, Sequence) is True for the same reason — the standard library registered tuple as a virtual subclass at import time.

The general guidance: register is rarely needed — duck typing (__subclasshook__) or Protocol covers most cases. Reach for it only when retrofitting a class you don’t control to satisfy isinstance(x, SomeABC).

25.5 Static Protocol types

typing.Protocol is the static-checker version of duck typing. You define an interface; any class with matching methods satisfies it — no inheritance required, no registration. We’ll define a SupportsLessThan protocol so we can write a function that accepts anything orderable, and have mypy verify the constraint without forcing callers to subclass anything.

from typing import Protocol, Any, runtime_checkable, TypeVar
from collections.abc import Iterable

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

isinstance(1, SupportsLessThan), isinstance("hi", SupportsLessThan)
(True, True)

Walking through the protocol:

  • class SupportsLessThan(Protocol): declares a structural type. The body lists the methods a value must have to qualify, with ... as the body of each (it’s a stub — the body is never run).
  • @runtime_checkable adds isinstance(x, SupportsLessThan) support. Without the decorator, isinstance would refuse to check it — protocols are static-only by default.
  • isinstance(1, SupportsLessThan) is True because int.__lt__ exists; same for str. Neither type was modified — they just shape-match the protocol.

Use it in annotations to constrain a generic:

LT = TypeVar("LT", bound=SupportsLessThan)

def top(series: Iterable[LT], length: int) -> list[LT]:
    return sorted(series, reverse=True)[:length]

top([3, 1, 4, 1, 5, 9, 2, 6], 3)
[9, 6, 5]

Walking through the generic:

  • LT = TypeVar("LT", bound=SupportsLessThan) defines a type variable that ranges over any type satisfying the protocol. bound=... is the constraint.
  • Iterable[LT] says “an iterable of some LT-comparable element type”; list[LT] ties the return type to the input — pass ints, get a list of ints.
  • mypy verifies at type-check time that every element of series has __lt__. At runtime there’s no check — Python just calls sorted, which uses < internally.

The general pattern: a Protocol is the static type system’s name for “structural duck typing.” Define the shape, accept anything that fits, let the type checker enforce it without forcing inheritance.

25.6 ABC vs Protocol: when to use which

Both formalize duck typing. The differences are practical:

ABC Protocol
Subtyping nominal (inherit or register) structural (just have the methods)
When checked runtime via isinstance static via mypy, optionally runtime
Partial implementations yes (mixin methods) no
Forces explicit dependency yes no

Use a Protocol when you want library code to be type-safe but you don’t want to force callers to inherit from anything (the Closeable, Iterable, SupportsLessThan style).

Use an ABC when you want runtime enforcement and you want to provide partial implementations the subclass can rely on (the Tombola style).

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

class ReadableCloseable(Closeable, Protocol):
    def read(self, n: int = -1) -> str: ...

Protocols compose, just like ABCs.

TipWhy this matters

ABCs enforce contracts at runtime via isinstance checks. Protocols enforce contracts at static analysis time via mypy. Both formalize duck typing. Use Protocol for library code that should be type-safe; use ABC when you want runtime enforcement and want to provide partial implementations (mixin methods on the ABC).

25.7 Build: a runtime-checkable Plugin protocol with a registry

A plugin system is the canonical use-case for typing.Protocol: many third-party classes need to satisfy one shape, but you don’t want to force them all to import a common base class. We’ll build a Plugin Protocol, two unrelated concrete plugins, and a registry that uses isinstance against the Protocol to validate at the boundary.

Step 1: define the Protocol. A plugin must expose a string name and a run(config) method. Mark it @runtime_checkable so the registry can use isinstance:

from typing import Protocol, runtime_checkable

@runtime_checkable
class Plugin(Protocol):
    name: str
    def run(self, config: dict) -> str: ...

The Protocol declares one attribute (name: str) and one method (run(self, config: dict) -> str). The body of run is ... — a stub Python never executes; the protocol only describes shape. @runtime_checkable tells Python “build the introspection so isinstance(obj, Plugin) works.”

Step 2: two plugins that satisfy the protocol by shape. Neither imports Plugin. A HelloPlugin (a regular class) and an EchoPlugin (a @dataclass) — both happen to fit the contract:

from dataclasses import dataclass

class HelloPlugin:
    name = "hello"
    def run(self, config: dict) -> str:
        return f"Hello, {config.get('who', 'world')}!"

@dataclass
class EchoPlugin:
    text: str
    name: str = "echo"
    def run(self, config: dict) -> str:
        return config.get("text", self.text)

[isinstance(HelloPlugin(), Plugin),
 isinstance(EchoPlugin(text="hi"), Plugin),
 isinstance("not a plugin", Plugin)]
[True, True, False]

HelloPlugin is a hand-written class; EchoPlugin is a dataclass — totally unrelated implementation strategies. Both have a name and a run of the right shape, so both pass isinstance(..., Plugin). A str doesn’t have name or run, so it fails. Crucially: neither plugin class inherits from Plugin, and Plugin doesn’t appear in either’s MRO. This is structural typing in action.

Step 3: a registry that validates at the boundary. The point of @runtime_checkable is to fail loudly on bad input at the entry point, while allowing the rest of the code to assume the type:

class Registry:
    def __init__(self) -> None:
        self._plugins: dict[str, Plugin] = {}

    def register(self, plugin: Plugin) -> None:
        if not isinstance(plugin, Plugin):
            raise TypeError(f"{plugin!r} does not satisfy the Plugin protocol")
        self._plugins[plugin.name] = plugin

    def run(self, name: str, config: dict) -> str:
        return self._plugins[name].run(config)

reg = Registry()
reg.register(HelloPlugin())
reg.register(EchoPlugin(text="default"))

[reg.run("hello", {"who": "Alice"}),
 reg.run("echo",  {"text": "ping"}),
 reg.run("echo",  {})]
['Hello, Alice!', 'ping', 'default']
class BrokenPlugin:
    pass                                   # missing both name and run

reg.register(BrokenPlugin())
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[12], line 4
      1 class BrokenPlugin:
      2     pass                                   # missing both name and run
      3 
----> 4 reg.register(BrokenPlugin())

Cell In[11], line 7, in Registry.register(self, plugin)
      5     def register(self, plugin: Plugin) -> None:
      6         if not isinstance(plugin, Plugin):
----> 7             raise TypeError(f"{plugin!r} does not satisfy the Plugin protocol")
      8         self._plugins[plugin.name] = plugin

TypeError: <__main__.BrokenPlugin object at 0x7f822c1fc2f0> does not satisfy the Plugin protocol

Registry.register checks isinstance(plugin, Plugin) and raises TypeError with a clear message if the value is shape-wrong. After that, reg.run(...) can trust the dict’s values are real plugins — no further checking needed inside. BrokenPlugin is rejected immediately because it has neither name nor run.

The build threads the chapter’s lessons through one practical case: a Protocol declares the shape (Step 1), unrelated classes satisfy it without inheritance — including a @dataclass (Step 2), and @runtime_checkable lets the registry use isinstance to enforce the contract at the boundary while the rest of the code stays trusting (Step 3). No ABC, no register() calls, no MRO games.

25.8 Exercises

  1. A duck for Sized. Write a class with only __len__ and confirm isinstance(x, collections.abc.Sized). What about Iterable — what’s the minimum?

  2. A new ABC. Define Stack(abc.ABC) with abstract push/pop methods and a concrete peek that returns the top without removing. Implement it with a list.

  3. Protocol for context managers. Define a ContextLike Protocol requiring __enter__ and __exit__. Annotate a function that takes one. Try passing a contextlib.suppress — does mypy accept it?

  4. register instead of inherit. Use Sequence.register(YourClass) to make a class look like a Sequence to isinstance. Why is this fragile?

  5. When duck typing wins. Name a case where duck typing (just call the method) is better than declaring a Protocol. Hint: the cost of declaring the protocol must justify its use.

25.9 Summary

Python’s four typing approaches — duck, goose, static, runtime — are not competing; they’re a toolkit. Duck typing is the lightest. ABCs add runtime enforcement and shared implementation. Protocol adds static enforcement without inheritance. Pick the one that solves your problem at the lowest cost.

Next, Chapter 26 is the cautionary chapter. Inheritance, especially multiple inheritance, has real costs — and Python gives you the tools to use it carefully.