Back to Blog
Python

Python Structural Typing vs Nominal Typing

python structural typing vs nominal typing: Understand the difference between Python's nominal type system and structural typing with Protocol, and learn when each app...

typingProtocoltype-checkingduck-typingtype-safety
Illustration comparing a nominal inheritance hierarchy with a structural protocol contract in Python typing.

Python's 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 python structural typing vs nominal typing matters in practice because it determines whether a type checker accepts a value. With nominal typing, isinstance(obj, BaseClass) is the runtime check and the static checker ver inheritance. With structural typing, the static checker verifies that the object's shape matches the protocol, and the runtime check requires @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 body marks the method as declared but not implemented. At runtime, PaymentGateway is a normal class, and its methods raise AttributeError if called directly. The protocol only exists for the type checker.

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, isinstance() does not work with protocols. A protocol without the decorator is an ordinary class, and isinstance(obj, PaymentGateway) would fail unless the object inherits from it. Adding @runtime_checkable enables runtime checks:

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 is the main limitation of @runtime_checkable, and it 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 for isinstance() checks because the interpreter inspects the class for the declared members. The cost is proportional to the number of members checked and is negligible for typical 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; older codebases can use typing_extensions.Protocol as a backport.

When a protocol is used in a hot path where runtime checks run frequently, prefer static type checking and reserve isinstance() for boundaries where untrusted or dynamic input must be validated.

python structural typing vs nominal typing: Practical Usage | RYUSLOG DEV