Defining Callback Protocols with call

TL;DR

A Protocol with a __call__ method types a callback far more precisely than Callable[...]. Callable[[str, int], None] can only say “two positional parameters of these types”; a call Protocol lets you name each parameter, mark some keyword-only, give defaults, and even declare overloads. Use a call Protocol whenever a callback’s keyword arguments or optional parameters are part of its contract.

The Callable form is compact but lossy: it collapses a signature into an ordered list of types and a return type. Real callbacks in Python are rarely that flat — they take keyword-only flags, optional parameters with defaults, and sometimes more than one valid shape. A callback Protocol — a Protocol whose only member is __call__ — captures all of that. This page sits under Protocol and structural subtyping and complements the callable signatures fundamentals.

Callable versus a callback Protocol Callable keeps only positional types and return type, while a Protocol with __call__ preserves parameter names, keyword-only flags, and defaults. Callable[[str, int], None] positional types only no parameter names no keyword-only args no defaults lossy contract LoggingHook(Protocol) named: event keyword-only: level default: level = 0 overloads allowed precise contract
A call Protocol keeps the parameter detail that Callable discards.

The lossy Callable form

Suppose you accept a logging hook that takes an event name and an optional severity keyword. The Callable annotation cannot express the keyword — you are forced to pretend both parameters are positional.

# Python 3.12+, checked with mypy 1.10 / pyright 1.1.370
from collections.abc import Callable

Hook = Callable[[str, int], None]      # both positional; 'level' name is lost

def record(event: str, *, level: int = 0) -> None:
    print(event, level)

def install(hook: Hook) -> None:
    hook("startup", level=2)           # mypy: [call-arg] unexpected keyword 'level'

mypy rejects the keyword call with [call-arg] (Unexpected keyword argument "level"), because Callable[[str, int], None] promises two positional parameters. The very shape of record is invisible to the annotation.

The precise callback Protocol

Define a Protocol whose __call__ mirrors the real signature. Now level is keyword-only with a default, and any callable matching that shape conforms structurally.

# Python 3.12+, checked with mypy 1.10 / pyright 1.1.370
from typing import Protocol

class LoggingHook(Protocol):
    def __call__(self, event: str, *, level: int = 0) -> None: ...

def record(event: str, *, level: int = 0) -> None:
    print(event, level)

def install(hook: LoggingHook) -> None:
    hook("startup", level=2)           # accepted — keyword-only 'level' is known
    hook("ready")                       # accepted — default supplies level=0

install(record)                         # record matches LoggingHook structurally

The parameter name event also becomes part of the contract, so a caller may write hook(event="startup"). If record dropped the * and made level positional, mypy would report the mismatch with [arg-type] at the install(record) call site.

Overloaded callbacks

A callback Protocol can hold @overload-ed __call__ definitions, which no Callable form can express. This is how you type a factory that returns different types depending on a flag.

# Python 3.12+, checked with mypy 1.10 / pyright 1.1.370
from typing import Protocol, overload, Literal

class Decoder(Protocol):
    @overload
    def __call__(self, raw: bytes, *, text: Literal[True]) -> str: ...
    @overload
    def __call__(self, raw: bytes, *, text: Literal[False] = False) -> bytes: ...

def call_decoder(decode: Decoder, blob: bytes) -> str:
    return decode(blob, text=True)      # picks the str overload

Analyzer behaviour

mypy and pyright both match callables against a call Protocol by comparing __call__ signatures, and both honour keyword-only markers and defaults. pyright is marginally stricter about parameter names when a caller uses them as keywords, so a name mismatch that mypy tolerates positionally may surface as reportArgumentType in pyright. A signature-shape mismatch is [arg-type] in mypy. For a broader treatment of these divergences see pyright vs mypy comparison.

Runtime vs static analysis A callback Protocol is erased at runtime — passing an ordinary function that "looks right" runs fine even if it violates the declared keyword-only rule, because Python resolves arguments by its own rules, not the annotation. The Protocol only constrains what the static checker accepts at the call site; it never rewrites how hook(...) is dispatched.

Common mistakes

  • Reaching for Callable when a keyword matters: the keyword call fails with [call-arg]. Switch to a __call__ Protocol so the keyword-only parameter is part of the type.
  • Adding extra members to a callback Protocol: if you add methods beyond __call__, a plain function will no longer match. Keep callback Protocols to __call__ alone unless you truly require the extra attributes.
  • Naming parameters differently across the Protocol and implementation: callers who use keywords will hit [call-arg] / reportArgumentType. Keep the parameter names identical to the intended keyword API.
  • Forgetting the default on the Protocol side: if __call__ lists level: int without = 0, callers omitting it are rejected with [call-arg] even though the concrete function has a default.

FAQ

When is Callable[...] still the better choice? When the callback is genuinely positional and simple — a comparator Callable[[T, T], int], a plain Callable[[], None] teardown. If there are no keyword-only parameters, defaults, or overloads to preserve, the compact form is clearer and needs no class definition.

Can a callback Protocol also carry attributes like __name__? Yes. Because it is a normal Protocol, you can add attributes (for example retries: int) alongside __call__. Only callables that also expose those attributes will then match, which is useful for typing configured callback objects rather than bare functions.

Back to Protocol and Structural Subtyping