Summary
A developer encountered an inconsistent behavior when using typing.Protocol for structural subtyping. Initially, attempting to use isinstance() on a protocol containing only methods failed with a TypeError unless the @runtime_checkable decorator was applied. However, when an attribute (a data member) was added to the protocol, the isinstance() check worked without the decorator, albeit with a logical flaw in how the check actually behaved. This postmortem explores the distinction between behavioral protocols and data protocols in the Python type system.
Root Cause
The discrepancy arises from how the Python interpreter and the typing module implement structural checks at runtime:
- Method-only Protocols: When a
Protocolcontains only methods, it is strictly a structural interface. By default,isinstance()calls are disabled for these because checking if an object “has a method” via reflection is computationally expensive and can lead to unpredictable results if methods are dynamically generated. The@runtime_checkabledecorator explicitly enables this attribute-presence check. - Data Protocols: Once a member like
x: intis added, the protocol is classified as a Data Protocol. - The Implementation Gap: When
isinstance(obj, A)is called on a Data Protocol without@runtime_checkable, the runtime check does not actually validate the type or even the existence of the attribute in a robust way. Instead, it falls back to a different internal logic path where the presence of a type hint for an attribute triggers a different branch in the CPython implementation ofisinstance.
Why This Happens in Real Systems
In large-scale production systems, this happens due to the divergence between static analysis and runtime enforcement:
- Static vs. Dynamic: Tools like
mypyorpyrightsee thex: intand assume structural compatibility. However, the Python runtime (CPython) handlesisinstancethrough a specific dispatch mechanism. - Attribute Resolution: Checking for the existence of a method requires a
hasattrcall, whereas checking for a data member often relies on the object’s__dict__or__slots__. - Optimization Shortcuts: The Python core developers optimized
isinstancefor classes and standard protocols. The “magic” that allows data protocols to pass without the decorator is effectively an unintended side effect of how the interpreter handles protocols containing non-method members during theisinstancedispatch.
Real-World Impact
- False Positives: The most dangerous impact is that
isinstance(B(), A)might returnTrueeven ifBdoes not actually possess the attributex. The check becomes shallow and unreliable. - Silent Failures: A developer might assume their code is type-safe because the
assertpassed, only to have aAttributeErroroccur minutes later in production when the code actually tries to accessobj.x. - Brittle Refactoring: Changing a method to a property (or vice-versa) can silently change the runtime behavior of the entire validation logic, leading to non-deterministic production crashes.
Example or Code
from typing import Protocol, runtime_checkable
class MethodProtocol(Protocol):
def f(self) -> None: ...
class DataProtocol(Protocol):
x: int
def f(self) -> None: ...
class B:
def f(self) -> None:
pass
# 1. This fails with TypeError (Correct behavior)
try:
isinstance(B(), MethodProtocol)
except TypeError as e:
print(f"Caught expected error: {e}")
# 2. This passes WITHOUT @runtime_checkable (Dangerous/Unexpected behavior)
# Even though B() does NOT have attribute 'x'
if isinstance(B(), DataProtocol):
print("DataProtocol check passed unexpectedly!")
# 3. The correct, safe way
@runtime_checkable
class SafeProtocol(Protocol):
def f(self) -> None: ...
assert isinstance(B(), SafeProtocol)
How Senior Engineers Fix It
Senior engineers do not rely on “magic” behavior or accidental side effects. To fix this, we follow these principles:
- Explicit over Implicit: Always use the
@runtime_checkabledecorator ifisinstanceorissubclassis required. Never rely on the presence of a data member to “unlock” runtime checking. - Strict Interface Definition: If the goal is to check for behavior (methods), keep the protocol as a behavioral protocol and decorate it explicitly.
- Defensive Programming: If runtime type checking is critical for a specific high-risk component, implement an explicit
validate()method or use a library likepydanticfor rigorous runtime validation rather than relying ontyping.Protocol‘sisinstance. - Documentation of Intent: Use comments to explain why a protocol is being used for runtime checks, warning future maintainers not to remove the decorator.
Why Juniors Miss It
- Trusting the “Green” Test: A junior engineer sees the
assertpass and assumes the logic is sound. They mistake a shallow runtime check for a deep structural validation. - Lack of Runtime Awareness: Juniors often view
typingas a purely static tool for IDE autocompletion. They may not realize thatisinstancetriggers actual execution logic in the CPython interpreter. - Surface-Level Debugging: When the error occurs, a junior might try to “fix” it by adding an attribute to the protocol to make the error go away, rather than investigating why the underlying mechanism changed.