Skip to content

← The Type System as a Design Tool step 22 of 24

Hard Framework

runtime_checkable is presence, not a signature

Engineers reach for hasattr believing it gives isinstance-grade safety. It gives them Any.

The hasattr hole, verified

def f(x: object) -> None:
    if hasattr(x, "quack"):
        reveal_type(x)          # object
        reveal_type(x.quack)    # Any
        x.quack(1, 2, 3, "nonsense", keyword=object())   # type-checks fine

Inside the branch the call is allowed, but x itself is still object and the attribute is Any. So every call through it is unchecked, with any arguments, in any arity. This is a bigger hole than most people assume — it is not “narrower typing”, it is no typing, in the one place you thought you had added a safety check.

@runtime_checkable is a smaller hole, but a hole

@runtime_checkable
class SupportsClose(Protocol):
    def close(self) -> None: ...

Now isinstance(x, SupportsClose) works and narrows x to SupportsClose, so x.close() is checked against the declared signature. Much better. But understand exactly what the runtime check does:

  • it checks member presence only — not signatures, not parameter types, not return types. A close taking a required argument passes. A close that is the integer 3 passes.
  • since Python 3.12 it uses inspect.getattr_static, which does not invoke the descriptor protocol. Two consequences: a __getattr__ catch-all no longer satisfies the protocol (it did before 3.12), and a @property that raises is no longer triggered by the check.
  • it is O(members) per call and noticeably slower than a nominal isinstance; it is not something to put in a hot loop.
  • it cannot be used with non-method members at all — a @runtime_checkable protocol with data attributes raises TypeError on isinstance.

The one-sentence version: @runtime_checkable + isinstance is not interface validation. It is a hasattr loop with better static typing bolted on.

The task

@runtime_checkable
class SupportsClose(Protocol):
    def close(self) -> None: ...

def close_all(objects: Iterable[object]) -> int: ...
def solve(kinds: list[str]) -> tuple[int, list[str]]: ...

close_all calls close() on everything that satisfies the protocol, counts the successes, and swallows whatever close() raises. A failure is not a success.

Six classes are provided along with a FACTORIES table keyed by name:

kind what it is isinstance? close()
plain an ordinary closer yes works
raising close raises OSError yes raises
arity close(self, force: bool) yes TypeError — missing argument
notcallable close = 3 yes TypeErrorint is not callable
dynamic serves close from __getattr__ no (3.12+) never called
noclose no such member no never called

solve constructs one object per kind, in order, and returns (close_all(objects), [type(o).__name__ for o in objects if isinstance(o, SupportsClose)]).

The second element is the teaching artefact: it is the set the runtime check accepted, and comparing it to the count shows you exactly how many of those acceptances were worthless.

What this forces you to write

close_all must be defensive in a way that looks paranoid until you read the table: the exception handler has to catch TypeError from a badly shaped close, not only the OSError from a legitimately failing one. A structural check told you the member exists. It told you nothing about whether calling it is meaningful.

That is the general shape of duck typing at a boundary: presence is cheap to verify, and behaviour is not verifiable at all. If you need behaviour, you need either a nominal type you control or a call wrapped in a handler — and usually both.

Loading visualization…