Skip to content

← Structural Typing and the Hard Parts step 7 of 24

Medium Framework

Callback protocols: closing the Callable[..., Any] hole

Callable[..., Any] in a plugin registry, an event bus or a DI container is the largest single Any-hole most codebases have — and mypy --strict will never mention it, because the annotation is technically complete. Every call made through that registry is unchecked: wrong argument count, wrong types, wrong keyword names, wrong return type. All silent.

What Callable can and cannot say

collections.abc.Callable[[A, B], R] expresses positional parameters only. There is no syntax in it for:

  • keyword-only parameters
  • parameter names
  • optional parameters with defaults
  • overloads

The moment you need any of those, the usual move is to widen to Callable[..., Any] and lose everything. The correct move is a callback protocol — a Protocol whose single member is __call__:

class Handler(Protocol):
    def __call__(self, payload: str, /, *, retries: int = 0) -> str: ...

That expresses one positional-only payload (the / marks it), one optional keyword-only retries, and a str return. And because parameter names are part of a callback protocol’s contract unless marked positional-only, a handler that spells the keyword retry instead of retries is now a static error rather than a TypeError in production.

Your task

Define the Handler callback protocol above, three handlers matching it, and a registry typed dict[str, Handler]:

  • shoutpayload.upper() + "!" * retries
  • tagf"[{retries}]{payload}"
  • trimpayload.strip() * (retries + 1)
def dispatch(name: str, payload: str, retries: int) -> str
def solve(names: list[str], payloads: list[str], retries: list[int]) -> list[str]

dispatch looks the handler up and calls it as handler(payload, retries=retries); an unknown name returns f"unknown:{name}". solve zips the three parallel input lists (use strict=True) and returns one result per entry.

The payoff

With the registry typed dict[str, Handler], adding a handler whose payload parameter is not positional-only, or whose keyword is misspelled, or which returns bytes, fails at the point of registration — the one place a human is looking. With Callable[..., Any] all three ship.

Loading visualization…