skip to content

How does a typing.Protocol with __call__ type a callback whose keyword arguments matter?

level: seniorimportance: should knowfreq 28%

answer

  1. Callable cannot name its parameters
  2. Put the signature on a dunder call member
  3. Keyword-only and defaults become expressible
  4. Any callable with a matching signature conforms
  5. ParamSpec instead when merely forwarding

basics

~20 s

Declare a protocol whose only member is call with the full signature, including keyword-only parameters and defaults. Any callable with a compatible signature conforms, which Callable cannot express because it carries only positional parameter types and a return type.

solid answer

~50 s

`collections.abc.Callable[[str, float], bytes]` can only say "two positional parameters of these types". It cannot express parameter *names*, keyword-only parameters, defaults, `*args`/`**kwargs`, or overloads -- so the moment a callback contract includes something like a keyword-only `timeout`, `Callable` is the wrong tool. A protocol with a single `__call__` member carries the entire signature: `class ConvertCallback(Protocol): def __call__(self, doc: str, *, timeout: float = 5.0) -> bytes: ...`. Conformance is structural as usual, so plain functions, lambdas, bound methods and instances of classes defining `__call__` all match if their signature is compatible. The catch is that parameter names become part of the contract -- renaming `timeout` in an implementation breaks conformance for callers who pass it by keyword. When you only need to forward an unknown parameter list unchanged, `typing.ParamSpec` is the lighter tool; the callable protocol is for pinning down a specific shape.

code

python · 23 lines
python
from typing import Protocol, runtime_checkable


@runtime_checkable
class HasCursor(Protocol):
    cursor: int

    def advance(self) -> None: ...


class Sync:
    def __init__(self) -> None:
        self.cursor = 0

    def advance(self) -> None:
        self.cursor += 1


print(isinstance(Sync(), HasCursor))
try:
    issubclass(Sync, HasCursor)
except TypeError as exc:
    print("TypeError:", exc)

go deeper

for a junior

Know that a callback annotated with Callable only pins positional parameter types and the return type, and that a class defining __call__ is callable just like a function.

for a middle

Explain how moving the signature onto a __call__ member captures keyword-only parameters and defaults, and which callables conform -- functions, lambdas, bound methods and callable instances alike.

for a senior

Show the production payoff: a plugin registry that rejects a mismatched callback at the type check instead of failing on a rare worker path, plus awareness that parameter names become a compatibility surface.

for a principal

Own the boundary policy: how callback contracts are versioned when a rename breaks keyword callers, when positional-only parameters should be mandated, and where ParamSpec-based forwarding is the right abstraction instead.

## The gap in `Callable` `Callable[[str, float], bytes]` is a positional-only description: two positional parameters, of type `str` and `float`, returning `bytes`. That is all it can say. It cannot express a parameter *name*, a keyword-only parameter, a default, a variadic tail, or more than one signature. In a codebase where a callback contract is "takes the document, plus a keyword-only `timeout`", `Callable` simply cannot write the contract down, and the usual workaround -- annotating the callback as `Callable[..., bytes]` -- throws away every parameter check. A protocol whose single member is `__call__` closes that gap, because `__call__` is an ordinary method declaration and therefore carries an ordinary, full signature: ```python from typing import Protocol class ConvertCallback(Protocol): def __call__(self, doc: str, *, timeout: float = 5.0) -> bytes: ... ``` Anything callable with a compatible signature now conforms: a module-level function, a lambda, a bound method, a `functools.partial`, or an instance of a class that defines `__call__`. Nothing inherits `ConvertCallback`, because conformance is structural. ## A worked shape Consider a document-conversion queue whose workers accept a pluggable converter and must bound each attempt, because conversions occasionally hang and surface as an intermittent timeout rather than a clean error. The queue's contract is not "some callable" -- it is "a callable I may invoke with the document positionally and a `timeout` by keyword": ```python def run_conversion(convert: ConvertCallback, doc: str) -> bytes: return convert(doc, timeout=1.5) ``` Now a plugin registered with a signature of `def to_pdf(doc: str) -> bytes` is rejected at registration by the checker, rather than blowing up as an unexpected-keyword `TypeError` on whichever worker happened to pick up that job. That is the whole payoff: the failure moves from a rare runtime path into the type check. ## Parameter names are part of the contract Because the consumer may pass `timeout` by keyword, the *name* is load-bearing. An implementation that spells it `deadline` does not conform, even with an identical type. This is a real maintenance constraint on a plugin boundary, and there are two ways to manage it. Make the parameter positional-only in the protocol -- put a `/` after it -- when you genuinely do not want the name pinned; or accept the pinning deliberately and treat a rename as the breaking change it is for keyword callers. The same logic applies to defaults: an implementation may add a default where the protocol has none, but not remove one the protocol promises, since callers are entitled to omit it. ## Variance, and what an implementation may change Standard function-compatibility rules apply. An implementation may accept a *wider* parameter type than the protocol declares (`object` where the protocol says `str`) and may return a *narrower* type (`bytes` where the protocol says `bytes | None`). It may also accept extra parameters as long as they all have defaults, since a caller honouring the protocol never passes them. It may not require anything the protocol does not promise to supply. ## Extras a callable protocol unlocks Because it is a class, a callable protocol can carry more than the call signature. It can declare additional members that a caller may inspect -- a `name: str` a registry displays, or a `ClassVar` capability flag -- so "callable and self-describing" becomes a single annotation. It can hold `typing.overload`-decorated `__call__` declarations to describe a callable whose return type depends on its arguments, which no `Callable` form can express. And it can be generic, so a family of callbacks parameterised by their payload type shares one declaration. ## When not to use one If the goal is to *forward* an arbitrary parameter list unchanged -- a decorator, a retry wrapper, a scheduler that stores a call for later -- the answer is `typing.ParamSpec`, optionally with `typing.Concatenate` to pin leading parameters, not a callable protocol. `ParamSpec` says "whatever this callable takes", which is exactly right for a wrapper and useless for a plugin contract. And when the contract really is just "one positional argument of this type", plain `Callable` remains the shorter, more readable annotation. The callable protocol earns its extra lines specifically when names, keyword-only parameters, defaults, overloads or extra attributes are part of what you are promising.

  • What breaks if an implementation renames the keyword parameter the protocol declares?
    Conformance breaks. Because the consumer may call the parameter by keyword, its name is part of the contract, so a `timeout` renamed to `deadline` is rejected even though the types are identical. If you do not want the name pinned, make it positional-only in the protocol with a `/`; otherwise treat a rename as a breaking change for every keyword caller.
  • When would you use typing.ParamSpec instead of a callable protocol?
    When you are forwarding an unknown parameter list rather than pinning a known one -- decorators, retry wrappers, schedulers that store a call for later. `ParamSpec` captures whatever the wrapped callable takes and replays it on the wrapper, with `Concatenate` if the wrapper injects leading parameters. A callable protocol is for the opposite case: a specific signature that plugins must honour.
  • Can a callable protocol describe a callable whose return type depends on its arguments?
    Yes -- declare several `typing.overload`-decorated `__call__` signatures inside the protocol, in the usual overload order from most specific to least. No `Callable` form can express that, since it carries exactly one parameter list and one return type. The protocol can also declare non-call members alongside, so a callback that must also expose a name or a flag is a single annotation.

saying these in an interview costs you the question

  • Claims Callable can express keyword-only parameters
  • Thinks the implementation must subclass the callable protocol
  • Ignores that parameter names bind the contract
  • Uses Callable with an ellipsis and calls it typed
  • Reaches for a callable protocol where ParamSpec is the fit
  • Believes only functions can satisfy it, not callable instances

context