Currently typing a callable with typing.Callable
is known for its drawbacks of looking rather verbose with nested square brackets required (unless the signature is omitted with ...
), being visually dissimilar to the syntax of a function signature, and being unable to support named arguments.
While we can use callback protocols or TypedDict
Unpack
ing to type named arguments of a callable, both solutions require an additional, separate definition from the definition of the callable, making the usage relatively indirect and cumbersome.
The closest we’ve got to typing a callable more ergonomically was PEP 677 – Callable Type Syntax | peps.python.org but it was rejected mostly because it requires new grammar rules to support what was deemed to be only an occasional need.
So what I’m proposing here is something similar to PEP-677 but without introducing new grammar rules.
The Proposal
Let’s make typing.Callable
callable so that the signature of a callable can be typed by calling Callable
with types as arguments, while the return value of the callable is typed in square brackets that follow.
Borrowing examples from PEP-677, a type checker should treat the following annotation pairs exactly the same with this proposal:
from typing import Awaitable, Callable, Concatenate, ParamSpec, TypeVarTuple
P = ParamSpec("P")
Ts = TypeVarTuple('Ts')
f0: Callable()[bool]
f0: Callable[[], bool]
f1: Callable(int, str)[bool]
f1: Callable[[int, str], bool]
f2: Callable(...)[bool]
f2: Callable[..., bool]
f3: Callable(str)[Awaitable[str]]
f3: Callable[[str], Awaitable[str]]
f4: Callable(**P)[bool]
f4: Callable[P, bool]
f5: Callable(int, **P)[bool]
f5: Callable[Concatenate[int, P], bool]
f6: Callable(*Ts)[bool]
f6: Callable[[*Ts], bool]
f7: Callable(int, *Ts, str)[bool]
f7: Callable[[int, *Ts, str], bool]
But more importantly, using the call syntax to type a signature allows us to elegantly specify named arguments inline:
from typing import TypedDict, Unpack, Protocol
class F8Callback(Protocol):
def __call__(a: int, *, option: str) -> bool: ...
class F8Options(TypedDict):
option: str
f8: Callable(int, option=str)[bool]
f8: F8Callback
def f8(a: int, **kwargs: Unpack[F8Options]) -> bool: ...
To make f4
and f5
work at runtime we can make ParamSpec
unpackable as a mapping with a single key of __param_spec__
and the ParamSpec
object as the value. That is, Callable(**P)
gets evaluated into Callable(**{'__param_spec__': P})
at runtime.
One obvious downside to this proposal is that by repurposing the call syntax it can’t support keyword-only and optional parameters (unless we add new sentinels and/or special forms), but I feel that this syntax satisfies enough use cases and is simple enough to implement and teach that it can be a viable alternative for most people before reaching for a separate callback protocol or TypedDict
.