Protocols for Structural Typing
Overview
In Python type hints, a `Protocol` defines a set of methods or attributes that an object must have, without requiring the object to inherit from a base class. It formalizes duck typing. For example, if you define a `Serializer` protocol with a `dump()` method, any class that implements `dump()` is considered a valid `Serializer` by the type checker, even if it doesn't subclass `Serializer`. from typing import Protocol, Any class Serializer(Protocol): def dump(self, obj: Any) -> str: ... class JSONSerializer: def dump(self, obj: Any) -> str: import json return json.dumps(obj) The mental model is "shape-matching": the type checker verifies if an object's "shape" (its methods and attributes) matches the `Protocol`'s required shape. It's an analogy to checking if a plug fits a socket—the brand of the plug doesn't matter, only the pin layout. def export_data(data: Any, serializer: Serializer) -> str: return serializer.dump(data) export_data({"id": 1}, JSONSerializer()) # Valid shape A sharp edge occurs when using `@runtime_checkable` for runtime `isinstance()` checks. The runtime check only verifies that the method names exist, ignoring parameter types and return types. from typing import runtime_checkable @runtime_checkable class Serializer(Protocol): def dump(self, obj: Any) -> str: ... class BrokenSerializer: def dump(self) -> None: pass isinstance(BrokenSerializer(), Serializer) # Returns True! Static type checkers will catch the signature mismatch, but runtime checks will blindly pass.
`Protocol`s allow you to write strongly typed code without coupling classes together through inheritance, leading to cleaner and more flexible designs.
Where used: Dependency Injection, Abstracting external libraries, Mocking in tests
Why learn this
- You can define what a function needs from an argument without forcing the caller to inherit from a specific base class.
- It makes testing easier because you can mock dependencies that match the `Protocol` instead of mocking complex base classes.
Code walkthrough
from typing import Protocol
class Logger(Protocol):
def log(self, msg: str) -> None: ...
class ConsoleLogger:
def log(self, msg: str) -> None:
print(f'Log: {msg}')
def process(logger: Logger) -> None:
logger.log('Started')
process(ConsoleLogger())
Focus: def process(logger: Logger) -> None:
Aha moment
from typing import Protocol, runtime_checkable
@runtime_checkable
class Swimmer(Protocol):
def swim(self) -> None: ...
class Fish:
def swim(self) -> None: pass
print(issubclass(Fish, Swimmer))
Prediction: What does `issubclass()` return?
Common guess: `False`, because `Fish` does not inherit from `Swimmer`.
It prints `True`. When a `Protocol` is decorated with `@runtime_checkable`, `issubclass()` structurally checks if `Fish` has the required methods, bypassing normal inheritance checks.
Common mistakes
- Inheriting from Protocol in the implementation: You don't need to inherit from the `Protocol` in your concrete classes. The type checker matches them structurally. Inheriting defeats the purpose of structural subtyping.
- Forgetting @runtime_checkable: By default, `Protocol`s are only for static type checking. If you want to use `isinstance()` with a `Protocol` at runtime, you must decorate the `Protocol` with `@runtime_checkable`.
- Trusting @runtime_checkable for exact method signatures: When using `@runtime_checkable`, `isinstance()` only verifies that the object possesses the method names defined in the `Protocol`. It completely ignores parameter counts and types, meaning runtime checks will pass even if the method signatures are fundamentally incompatible.
Glossary
- duck typing
- A programming concept where an object's suitability is determined by its methods and properties rather than its explicit class type. Example: `obj.quack()`.
- 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: `class Quacker(Protocol):`.
Recall questions
- What is the primary advantage of using a `Protocol` over an Abstract Base Class?
- How does the type checker verify if an object satisfies a `Protocol`?
- What limitation does `@runtime_checkable` have when used with a `Protocol` and `isinstance()`?
Understanding checks
What does this code print, and why?
`True`
The `Duck` class implements the `quack()` method, matching the `Quacker` `Protocol`. Because `Quacker` is decorated with `@runtime_checkable`, `isinstance()` structurally checks `Duck` at runtime.
Does a type checker raise an error for passing `User()` to `save_entity()`?
`No`
`User` implements the `save()` method required by the `Saver` `Protocol`. Even though `User` doesn't inherit from `Saver`, the type checker recognizes it as a valid `Saver`.
Practice tasks
Define a Protocol for rendering
Given this function that expects an object with a `render() -> str` method, define a `Renderer` `Protocol` and use it as the type hint for the `obj` parameter.
Challenge
Mocking with Protocols
Create a `Notifier` `Protocol` with a `notify(msg: str)` method. Create an `EmailNotifier` class that implements it. Create a `send_alert` function taking a `Notifier` and calling `notify('Alert!')`.
Continue learning
Previous: Duck Typing