Skip to content

← Structural Typing and the Hard Parts step 5 of 24

Medium Primitives

@runtime_checkable checks names, not types

Teams reach for @runtime_checkable as a plugin-validation gate — “if it passes isinstance, it implements the interface” — and get something much weaker than they think. isinstance against a protocol verifies that the attribute names exist. Nothing else.

What is actually verified

All of the following are true, and all of them are checkable in this problem:

  • A class whose close() takes no arguments passes against a protocol demanding close(timeout: float). Signatures are not compared.
  • An attribute holding None satisfies a member declared name: str. Value types are not compared.
  • issubclass() against a protocol with any non-method member raises TypeError: Protocols with non-method members don't support issubclass(). (mypy will also refuse to compile the call — error code misc.)
  • isinstance against an undecorated protocol raises TypeError too.
  • Since 3.12 the lookup uses inspect.getattr_static, so a __getattr__ that fabricates attributes no longer fools it, and the member list is frozen at protocol-class creation.
  • The docs recommend plain hasattr on hot paths — the protocol check is considerably slower and buys you nothing extra.

Your task

You are given five candidate classes, a data protocol Backend (name: str plus close(timeout: float)), and a method-only protocol Closable. Implement:

def members_valid(obj: object) -> bool
def select_backends(candidates: Iterable[object]) -> list[str]
def closable_subclasses(candidates: Iterable[object]) -> list[str]
def solve(candidates: list[str]) -> dict[str, object]

members_valid is the check isinstance does not do: name must actually hold a str, close must be callable, and its signature must have a timeout parameter. Use inspect.signature, and guard it — signature() raises TypeError/ValueError on some callables.

select_backends keeps a candidate only if it passes isinstance and members_valid; closable_subclasses reports which candidate types pass issubclass(type(c), Closable). Both return type(c).__name__, preserving input order.

solve builds each candidate named in candidates from REGISTRY and returns:

{"isinstance_accepts": ..., "validated_accepts": ..., "closable_subclasses": ...}

The gap between the first two lists is the entire lesson: NullName and NoTimeout are decoys with all the right attribute names and the wrong contents, and the naive check waves them straight through.

Where the type system does the work

members_valid must take object, not Backend. If you narrow first and then re-check the narrowed value, mypy knows obj.name is already str, the failure branch becomes unreachable, and --warn-unreachable fires. The validation has to run on the un-narrowed value — which is also the honest description of what it is doing.

Loading visualization…