Python Structural Typing vs Nominal Typing
Understand the difference between Python's nominal and structural typing, how Protocol declares structural contracts, and when @runtime_checkable makes isinstance checks work.
Python's static type system is nominal by default. Two classes are considered compatible when one inherits from the other, either directly or through a chain of base classes. Structural typing, defined in PEP 544 and exposed through typing.Protocol, changes that rule: a class is compatible with a protocol when it provides the required attributes and methods, regardless of its inheritance hierarchy.
The distinction between structural and nominal typing matters in practice because it determines whether a type checker accepts a value. With nominal typing, a class is accepted when it inherits from the expected base, and isinstance(obj, BaseClass) is the corresponding runtime check. With structural typing, the static checker verifies that the object's shape matches the protocol; to use isinstance() at runtime, the protocol must be marked with @runtime_checkable.
How nominal typing works through inheritance
Consider a simple domain model:
class PaymentGateway: def charge(self, amount: float) -> str: return f"charged {amount}" class StripeGateway(PaymentGateway): def charge(self, amount: float) -> str: return f"stripe charged {amount}" def process(gateway: PaymentGateway) -> str: return gateway.charge(10.0)
The type checker accepts StripeGateway because it inherits from PaymentGateway. It also accepts PaymentGateway itself. It rejects an unrelated class even if that class happens to define charge:
class ManualGateway: def charge(self, amount: float) -> str: return f"manual {amount}" process(ManualGateway()) # type error
That rejection is the defining behavior of nominal typing. The relationship is declared through inheritance, not inferred from the shape of the class.
Declaring structural contracts with Protocol
typing.Protocol lets you define a structural contract without forcing implementers to inherit from a shared base:
from typing import Protocol class PaymentGateway(Protocol): def charge(self, amount: float) -> str: ... class StripeGateway: def charge(self, amount: float) -> str: return f"stripe charged {amount}" def process(gateway: PaymentGateway) -> str: return gateway.charge(10.0) process(StripeGateway()) # accepted
The ellipsis in the protocol method body marks the method as required but not implemented by the protocol class itself. A protocol is primarily a typing construct; concrete classes such as StripeGateway supply the real behavior.
Protocols can also declare attributes, not just methods:
class Named(Protocol): name: str class User: def __init__(self) -> None: self.name = "ada" def greet(entity: Named) -> str: return f"hello {entity.name}"
A class satisfies the protocol when it has the declared attribute and method names with compatible types.
Runtime checks with @runtime_checkable
By default, a protocol is not runtime-checkable. Without @runtime_checkable, isinstance(obj, PaymentGateway) raises TypeError rather than checking the object's shape. Adding @runtime_checkable enables the structural check:
from typing import Protocol, runtime_checkable @runtime_checkable class PaymentGateway(Protocol): def charge(self, amount: float) -> str: ... class StripeGateway: def charge(self, amount: float) -> str: return f"stripe charged {amount}" print(isinstance(StripeGateway(), PaymentGateway)) # True
The check verifies only the presence of the declared members. It does not verify method signatures, attribute types, or return types. A class with a charge method that accepts different parameters still passes the runtime check. This limitation is why runtime checks are useful for validation but not a substitute for static analysis.
Choosing between nominal and structural typing
Use nominal typing when the relationship between types is part of the domain model. Base classes communicate intent, provide shared implementation, and allow isinstance() checks without decoration. They fit cases where inheritance is the natural modeling tool, such as a common interface with default behavior.
Use structural typing when you want to accept any object that satisfies a contract without forcing implementers to import or inherit from your class. This is valuable for libraries that define interfaces for third-party code, for adapting existing classes, and for duck-typed code that should remain flexible.
The decision can be stated concretely:
- Nominal: you control the hierarchy, and implementers are expected to inherit from your base class.
- Structural: you want to accept existing classes that already have the required methods, and you do not want to force inheritance.
Maintainability and compatibility considerations
Structural typing keeps interfaces decoupled from implementation, which reduces import dependencies and makes it easier to test with lightweight fakes. The cost is that a protocol does not document the relationship as explicitly as a base class does, and a class that accidentally matches a protocol will be accepted without intent.
@runtime_checkable adds a small runtime cost to isinstance() checks because the runtime inspects the class for the declared members. The cost grows with the number of members checked and is usually small for normal validation paths, but it is not free.
Protocols are supported by mypy, Pyright, and Pyre, so the choice does not lock you into a specific tool. The typing.Protocol import is available from Python 3.8 onward; projects on earlier Python versions can use typing_extensions.Protocol as a backport.
When protocol runtime checks would run in a hot path, prefer static type checking and reserve isinstance() for boundaries where untrusted or dynamic input must be validated.