Understanding Python Structural Typing with Protocol
python structural typing: Learn how Python structural typing with Protocol lets you define flexible type contracts without inheritance, and how to use runtime checks e...
Python's type system is nominally typed by default: a class is considered a subtype of another only if it explicitly inherits from it. This works well for many applications, but it becomes restrictive when you want to accept any object that provides a certain set of methods or attributes, regardless of its class hierarchy. Python structural typing, implemented through the Protocol class from typing, gives you a way to define such contracts without forcing inheritance. This article explains how to use Protocol for structural subtyping, how to make those checks work at runtime, and where the approach has limits.
What Structural Typing Means in Python
Structural typing means that compatibility between types is determined by the shape of the object—its methods and attributes—rather than by its declared ancestry. In Python, this idea is often associated with duck typing: if an object has a read() method, you can treat it as a file-like object. The typing.Protocol class formalizes this for static type checkers and, optionally, for runtime checks.
Consider a function that expects an object with a save() method. With nominal typing, you might define an abstract base class and require all callers to inherit from it. With structural typing, you can define a Protocol that lists save(), and any class that happens to implement save() will be accepted, even if it has no relationship to the protocol.
Defining a Protocol for Structural Subtyping
To create a protocol, subclass Protocol from typing and declare the methods and attributes that must be present. Here is a minimal example:
from typing import Protocol class Saveable(Protocol): def save(self, path: str) -> None: ...
Any class with a save method that takes a string and returns None is now structurally compatible with Saveable. You do not need to inherit from Saveable. For instance:
class Document: def save(self, path: str) -> None: print(f"Saving to {path}") def persist(obj: Saveable) -> None: obj.save("/tmp/data")
The function persist accepts any object that satisfies the protocol, not just instances of classes that explicitly inherit from Saveable. This is the core of structural typing: the contract is defined by the protocol, not by the class hierarchy.
Using Protocol with Type Hints and isinstance Checks
Protocols are primarily used by static type checkers like mypy or pyright. At runtime, a protocol is just a regular class, and isinstance(obj, Saveable) will fail unless you decorate the protocol with @runtime_checkable. That decorator enables isinstance and issubclass to perform a structural check based on the protocol's members.
from typing import Protocol, runtime_checkable @runtime_checkable class Saveable(Protocol): def save(self, path: str) -> None: ... class Document: def save(self, path: str) -> None: print(f"Saving to {path}") doc = Document() print(isinstance(doc, Saveable)) # True
The runtime check verifies that the object has the required methods and attributes. It does not verify signatures or return types. That means a class with a save method that takes no arguments would still pass the isinstance check, even though it would fail static analysis. This is an important limitation to keep in mind when using runtime_checkable for validation.
Common Patterns: Duck Typing with Explicit Contracts
Protocols shine in scenarios where you want to accept a variety of objects that share a common interface. For example, you might have a function that processes any iterable of numbers, or any object that supports the context manager protocol. Instead of checking for a specific concrete type, you can define a protocol that captures the essential behavior.
from typing import Protocol, Iterable class NumberSource(Protocol): def __iter__(self) -> Iterable[int]: ... def total(source: NumberSource) -> int: return sum(source)
This function accepts any iterable of integers—lists, tuples, generators, or custom classes—as long as they implement __iter__. The protocol makes the contract explicit without tying the function to a particular implementation.
Another common use is for file-like objects. You can define a protocol with read and close methods and use it in functions that read from a file or a network response.
Structural Typing with TypeVar and Generic Protocols
Protocols can be generic, which allows you to express relationships between types. For example, a protocol that represents a container that can produce items of a specific type:
from typing import Protocol, TypeVar T = TypeVar("T") class Producer(Protocol[T]): def produce(self) -> T: ... class IntFactory: def produce(self) -> int: return 42 class StrFactory: def produce(self) -> str: return "hello" def get_value(factory: Producer[T]) -> T: return factory.produce()
The generic protocol lets the function get_value preserve the type of the produced value. If you pass an IntFactory, the return type is inferred as int; if you pass a StrFactory, it becomes str. This combination of structural typing and generics gives you flexible yet type-safe abstractions.
Runtime Cost and Compatibility Considerations
The main runtime cost of structural typing comes from @runtime_checkable. When you call isinstance(obj, SomeProtocol), Python must inspect the object's attributes and methods to see if they match the protocol's members. This is slower than a normal isinstance check against a concrete class, which only compares the type's MRO. The overhead is usually small for a handful of members, but it can add up if you perform many checks in a hot loop.
More importantly, runtime_checkable only checks for the presence of attributes and methods. It does not verify that those attributes are callable, nor does it check their signatures. This can lead to false positives if a class happens to have a method with the same name but a different meaning. For example, a class with a save method that takes no arguments will pass the isinstance check, but calling save(path) will raise a TypeError. Static type checkers will catch this, but runtime checks will not.
Compatibility is another consideration. Protocols are a Python 3.8 feature (PEP 544). If you need to support Python 3.7 or earlier, you can use the typing_extensions package, which provides a backport. When using runtime_checkable, be aware that it only works with protocols that have no non-method members (like attributes) in some older versions; this limitation was lifted in later Python versions, but it is worth testing on your target runtime.
Where Structural Typing Breaks Down
Structural typing is not a replacement for nominal typing. There are cases where you want to enforce an explicit inheritance relationship, such as when you need to use super() calls or when you want to guarantee that a class implements a specific set of methods with correct semantics. Protocols only describe the shape, not the behavior. Two classes with a save method might have completely different side effects, and the protocol cannot distinguish between them.
Another limitation is that protocols do not enforce attribute types at runtime. Even with runtime_checkable, an attribute that exists but has the wrong type will still pass the check. For example, if a protocol declares value: int, a class with value = "string" will pass isinstance because the attribute exists. Static type checkers will flag this, but runtime validation will not.
Finally, structural typing can make code harder to navigate. When a function accepts a protocol, you cannot easily jump to the concrete implementations by inspecting the class hierarchy. You have to rely on static analysis tools to find all classes that satisfy the protocol. This is a maintainability tradeoff: you gain flexibility but lose some directness in code exploration.