How does a typing.Protocol with __call__ type a callback whose keyword arguments matter?
answer
- Callable cannot name its parameters
- Put the signature on a dunder call member
- Keyword-only and defaults become expressible
- Any callable with a matching signature conforms
- ParamSpec instead when merely forwarding
basics
~20 sDeclare 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 linesfrom 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
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.
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.
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.
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