Structural Subtyping with Protocols

RoadmapsPython

Overview

Python's `typing.Protocol` allows you to define structural interfaces. Instead of checking if an object inherits from a specific class (nominal typing), Protocols check if an object has the right methods and attributes (structural typing). Think of a Protocol like a bouncer at a club who only checks if you have a valid ID card, not what family you belong to. from typing import Protocol class Logger(Protocol): def log(self, message: str) -> None: ... Any class that implements log implicitly matches the Logger Protocol, without needing to explicitly inherit from it. This gives you the safety of static typing with the flexibility of Python's natural duck typing (determining object suitability by its methods rather than its type). from typing import runtime_checkable, Protocol @runtime_checkable class Quacker(Protocol): def quack(self) -> None: ... class BadDuck: def quack(self, volume: int) -> None: pass print(isinstance(BadDuck(), Quacker)) At runtime, isinstance() against a @runtime_checkable protocol only verifies that the method names exist. It does not inspect the method signatures, meaning incompatible arguments can slip through and cause runtime crashes despite passing the isinstance check.

It decouples your code. You can specify what a function needs an object to do rather than what the object is, making it much easier to swap implementations or mock objects for testing without creating complex inheritance hierarchies.

Where used: Dependency injection, Mocking in tests, Plugin architectures

Why learn this

Code walkthrough

from typing import Protocol

class Drawable(Protocol):
    def draw(self) -> str:
        ...

class Circle:
    def draw(self) -> str:
        return 'O'

class Square:
    def draw(self) -> str:
        return '[]'

def render(shape: Drawable) -> None:
    print(shape.draw())

render(Circle())
render(Square())

Focus: render(Circle())

Aha moment

from typing import Protocol, runtime_checkable

@runtime_checkable
class Quacker(Protocol):
    def quack(self) -> None:
        ...

class Duck:
    def quack(self) -> None:
        print('Quack!')

donal = Duck()
print(isinstance(donal, Quacker))

Prediction: What does isinstance return, given that Duck does not inherit from Quacker?

Common guess: False, because Duck does not subclass Quacker.

It prints True. Because Quacker is a untime_checkable Protocol, isinstance() checks if donal has a quack() method, not its class hierarchy.

Common mistakes

Glossary

nominal typing
A type system where a variable's type is determined by the explicit name of its class or inheritance, rather than its structure. Example: isinstance(obj, MyClass).
structural subtyping
A type system where a variable's type is determined by the methods and properties it has, rather than its explicit class name. Example: obj.save().

Recall questions

Understanding checks

Will this code pass structural type checking?

Yes, it will pass.

Because DiskSaver has a save method matching the signature required by the Saver protocol, it structurally matches even though it doesn't explicitly inherit from Saver.

What happens if you use isinstance(obj, MyProtocol) without decorating MyProtocol with @runtime_checkable?

It will raise a TypeError at runtime.

By default, Protocols are strictly for static type checking. Python cannot use them in isinstance checks at runtime unless explicitly marked with @typing.runtime_checkable.

What will this code print?

True

At runtime, isinstance() with a @runtime_checkable protocol only checks if the method name (speak) exists. It does not verify the method signature, even though the parameters are incompatible.

Practice tasks

Define a Notifier Protocol

Modify the starter code to define a `Notifier` Protocol with a `send(message: str) -> None` method. Then implement the `notify_all` function to iterate through the notifiers and call `send` on each one.

Challenge

A Generic Storage Protocol

You are building an app that can save data to multiple places (e.g., S3, local disk, database). Define a `Storage` protocol that requires a `save(filename: str, data: str) -> bool` method. Then, write a concrete class `LocalDiskStorage` that implements this shape (it can just print `"Saving {data} to {filename}"` and return `True`). Finally, write a function `backup_data(storage: Storage)` that uses the protocol.

Return to Python Roadmap