Using Python Typing Protocol for Structural Subtyping
python typing protocol: Learn how to use typing.Protocol for structural subtyping, define protocols, enable runtime checks, and apply them in dependency injection and...
When you write Python with type hints, you often want to describe the shape of an object rather than its exact class. The python typing protocol mechanism, introduced in PEP 544, gives you a way to define structural subtyping: an object is considered compatible with a protocol if it has the required attributes and methods, regardless of its actual inheritance chain. This is a direct answer to the duck typing style that Python developers already use, but with static type checking support.
Defining a Protocol with typing.Protocol
The core of the feature is the Protocol class from the typing module. You create a subclass of Protocol and declare the methods and attributes that any conforming type must have. Here is a minimal example:
from typing import Protocol class Drawable(Protocol): def draw(self) -> None: ...
This protocol requires any conforming type to have a draw method that takes no arguments and returns None. The ellipsis (...) is a placeholder for the method body; it signals that the implementation is not provided. A class that has a draw method is structurally compatible with Drawable, even if it does not inherit from it:
class Circle: def draw(self) -> None: print("Drawing a circle") class Square: def draw(self) -> None: print("Drawing a square") def render(shape: Drawable) -> None: shape.draw() render(Circle()) # OK render(Square()) # OK
The type checker (mypy, Pyright, or pytype) will accept both Circle and Square as valid arguments to render because they satisfy the Drawable protocol. At runtime, no check happens unless you explicitly use isinstance with a runtime-checkable protocol. The protocol itself is just a class definition; it does not enforce anything by itself.
Protocol vs. Abstract Base Classes
Abstract Base Classes (ABCs) enforce nominal subtyping: a class must explicitly inherit from the ABC to be considered a subclass. Protocols, on the other hand, enforce structural subtyping. This difference has practical consequences.
| Aspect | ABC (Abstract Base Class) | Protocol (typing.Protocol) |
|---|---|---|
| Inheritance required | Yes, class must inherit from ABC | No, structural compatibility is enough |
| Runtime enforcement | Can use isinstance and issubclass | Only if decorated with @runtime_checkable |
| Static type checking | Works with nominal typing | Works with structural typing |
| Use case | When you control the class hierarchy | When you want to accept any object with the right shape |
Consider a function that needs to read from a file-like object. With an ABC, you would require the object to inherit from io.IOBase or a custom ABC. With a protocol, you can define a Readable protocol that requires a read method, and any object with that method—even a custom class that does not inherit from any common base—will be accepted. This is particularly useful when integrating third-party libraries or legacy code where you cannot modify the class hierarchy.
Using runtime_checkable for Runtime Validation
By default, protocols are not runtime-checkable. If you try to use isinstance with a plain protocol, you get a TypeError. To enable runtime checks, decorate the protocol with @runtime_checkable:
from typing import Protocol, runtime_checkable @runtime_checkable class Drawable(Protocol): def draw(self) -> None: ... print(isinstance(Circle(), Drawable)) # True print(isinstance(42, Drawable)) # False
The @runtime_checkable decorator makes the protocol participate in isinstance and issubclass checks by inspecting the object's attributes. However, this check is shallow: it verifies that the required methods exist and are callable, but it does not validate their signatures. For example, a class with a draw method that takes an argument would still pass the check, even though it does not conform to the protocol's signature. This limitation is documented and should be considered when relying on runtime validation.
Runtime checks are useful for defensive programming and for libraries that need to validate input at runtime. But they add a small overhead because each check involves attribute lookups. In performance-critical paths, avoid repeated isinstance checks against runtime-checkable protocols; instead, rely on static type checking and trust the caller.
Practical Example: Dependency Injection with Protocols
Protocols shine in dependency injection and plugin architectures. Suppose you are building a notification service that can send messages through different channels. Instead of tying the service to a concrete class, you define a protocol:
from typing import Protocol class Notifier(Protocol): def send(self, message: str) -> None: ... class EmailNotifier: def send(self, message: str) -> None: print(f"Email: {message}") class SMSNotifier: def send(self, message: str) -> None: print(f"SMS: {message}") def notify(notifier: Notifier, message: str) -> None: notifier.send(message) notify(EmailNotifier(), "Hello") notify(SMSNotifier(), "Hello")
Here, notify does not care about the concrete class of the notifier; it only needs an object with a send method. This makes the code more flexible and easier to test. You can easily swap implementations without changing the function signature. Static type checkers will catch mismatches early, such as passing an object that lacks send.
Common Pitfalls and How to Avoid Them
One common mistake is to use a protocol as a base class for concrete classes. Protocols are meant to be structural contracts, not implementations. If you inherit from a protocol, you must still implement all methods; otherwise, the class remains abstract. More importantly, inheriting from a protocol can create confusion about whether you are relying on structural or nominal typing. Prefer composition: define protocols separately and let concrete classes implement them implicitly.
Another pitfall is forgetting to include all required attributes. A protocol can require data attributes as well as methods. For example:
class Point(Protocol): x: int y: int
Any class with x and y attributes (as instance variables) satisfies this protocol. If you forget to define one of them, the type checker will report an error. This is helpful, but it also means that a class with x and y as class attributes will also pass, which might not be what you intended. Be explicit about whether you expect instance attributes or class attributes.
A third issue is using protocols with @runtime_checkable on methods with complex signatures. As mentioned, the runtime check does not validate signatures. If you need strict runtime validation, consider using dataclasses or manual attribute checks instead of relying solely on protocols.
Performance and Overhead of Protocol Checks
Protocols themselves have zero runtime cost when used only for static type checking. The Protocol class is just a regular class; it does not add any metaclass magic or runtime hooks unless you use @runtime_checkable. The overhead appears only when you call isinstance or issubclass on a runtime-checkable protocol. Each check performs attribute lookups for every required member. For a protocol with many methods, this can be noticeable in tight loops.
If you need to validate objects repeatedly, cache the result or perform the check once at the boundary. For example, in a web framework, you might validate a request object against a protocol once during deserialization, then rely on static typing for the rest of the request lifecycle. This keeps the runtime cost low while still catching mismatched objects early.
Another performance consideration is the impact on type checker speed. Large numbers of protocols and complex structural relationships can slow down static analysis, but this is rarely a practical concern for typical projects. If you notice slow type checking, consider simplifying your protocol definitions or using TypeAlias to reduce redundancy.
When to Use Protocols vs. Other Typing Constructs
Protocols are not the only tool for expressing type constraints. You might also use Union, TypeVar, or Callable. The choice depends on the flexibility you need.
- Use a protocol when you want to accept any object with a specific set of methods or attributes, regardless of its class hierarchy.
- Use a
TypeVarwith a bound when you need to preserve the exact type of the argument while restricting it to a specific subclass. - Use a
Callablewhen you only need a function with a particular signature, not an object with multiple methods. - Use a
Unionwhen you have a fixed, finite set of acceptable types.
For example, if you need to accept either a str or a bytes object, a Union is appropriate. If you need to accept any object that has a read method, a protocol is better. If you need to accept any subclass of a base class and return the same type, a TypeVar with a bound is the right choice.
Protocols also work well with generic types. You can define a generic protocol that specifies a type parameter, allowing you to express relationships like a container that yields items of a certain type:
from typing import Protocol, TypeVar, Iterator T = TypeVar("T") class Iterable(Protocol[T]): def __iter__(self) -> Iterator[T]: ...
This generic protocol can be used to type functions that operate on any iterable of a specific type, while still preserving the element type. This is a powerful combination that gives you both flexibility and type safety.
Finally, remember that protocols are a typing construct, not a runtime feature. They do not change the behavior of your code. They exist to give static type checkers more information and to make your intentions clear to other developers. Use them liberally in public APIs and internal interfaces where structural compatibility is important, but do not expect them to enforce behavior at runtime unless you explicitly add runtime checks.