Python runtime_checkable and isinstance Protocols
Python's typing.Protocol introduces structural subtyping
(static duck typing), but protocols cannot be used with runtime type
checks by default. The @typing.runtime_checkable decorator
bridges this gap by allowing protocols to be used with
isinstance() and issubclass(). This article
explains how @typing.runtime_checkable enables dynamic
protocol validation, how it evaluates objects under the hood, and the
key limitations developers must consider when using it.
The Default Behavior of Protocols
In standard Python static typing, a Protocol defines an
interface for static type checkers like Mypy or Pyright:
from typing import Protocol
class Closable(Protocol):
def close(self) -> None:
...If you attempt to validate an object against this protocol
dynamically using isinstance(obj, Closable), Python raises
a runtime error:
TypeError: Instance and class checks can only be used with @runtime_checkable protocols
By default, protocols exist purely for static analysis and discard their structural definitions during runtime evaluations to minimize performance overhead.
The Function of
@typing.runtime_checkable
Marking a protocol with @typing.runtime_checkable alters
its metaclass implementation, enabling Python's dynamic type-checking
functions (isinstance() and issubclass()) to
inspect the target object at runtime:
from typing import Protocol, runtime_checkable
@runtime_checkable
class Closable(Protocol):
def close(self) -> None:
...
class Resource:
def close(self) -> None:
print("Resource closed")
res = Resource()
print(isinstance(res, Closable)) # Returns: TrueWhen @runtime_checkable is applied,
isinstance(obj, Closable) checks whether the object
obj possesses all the attributes and methods defined inside
the Closable protocol. Explicit inheritance from the
protocol is not required; any class that implements the required members
will evaluate to True.
How the Runtime Validation Works
When isinstance(obj, Protocol) executes, Python inspects
the object's attribute dictionary:
- Attribute Existence: It verifies that every non-callable variable defined in the protocol exists on the instance or class.
- Callable Verification: It ensures that every method declared in the protocol exists on the object and is callable.
Critical Limitations
While @typing.runtime_checkable allows structural
checks, it performs only a shallow verification:
- No Signature Validation: It only verifies that a
method exists and is callable. It does not inspect method arguments,
parameter counts, or keyword arguments. A method defined as
def close(self, force: bool, timeout: int)will still satisfy a protocol expectingdef close(self). - No Type Annotation Validation: It does not validate return types or argument types at runtime.
- Complex Attributes: Non-method attributes are only
checked for existence, not for matching types. If a protocol expects
count: int, an object withcount = "invalid"will still passisinstance().
Because of these limitations, @typing.runtime_checkable
provides basic runtime structural duck-typing rather than complete type
safety. Full type contracts should still be enforced with static
analysis tools.