Make Callable callable for typing callables

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 Unpacking 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.

2 Likes

I couldn’t find any reference to this, but I am almost sure that call expressions are not valid type annotations. If it is indeed invalid, do you propose to lift this restriction in general, or special case Callable?

If the proposal is general, is the following allowed?

from abc import ABC, abstractmethod

class FooBase(ABC):
    @abstractmethod
    def bar(self, x: int) -> int: ...

def foo_factory(param: int) -> type[FooBase]:
    class Foo(FooBase):
        def bar(self, x: int) -> int:
            return param + x
    return Foo

str_foo: foo_factory(5)

For reference of such code you can check lancedb.pydantic.Vector, which is generally suggested to be used like this:

from lancedb.pydantic import LanceModel, Vector
from lancedb.embeddings import get_registry


db = lancedb.connect("/tmp/db")
func = get_registry().get("openai").create(name="text-embedding-ada-002")

class Words(LanceModel):
    text: str = func.SourceField()
    vector: Vector(func.ndims()) = func.VectorField()

...
1 Like

This is not correct and can be found in the docs, like the full grammar specification. You can also try it yourself:

Python 3.15.0a0 (May 20 2025, 22:00:00) [GCC 14.2.1] on linux
Type "help", "copyright", "credits" or "license" for more information.

>>> var:print("abc") = 0
>>> def ham(x:print("abc")): pass
>>> import annotationlib
>>> annotationlib.get_annotations(ham)
abc
{'x': None}

Older versions of Python execute the print function immediately.

Following this, I think all aspects brought up by Sayandip are out of scope of this discussion since Ben did not propose any changes to the language itself. (Edit: See below)

Just for clarity: This is not an endorsement of Ben’s proposal.

I was not talking about runtime behavior, I was referring to typing specs. Here is the result of the following line in different type checkers:

x: print() = None

# mypy: main.py:1: error: Invalid type comment or annotation  [valid-type]
# pyright: Call expression not allowed in type expression  (reportInvalidTypeForm)
# pyrefly: ERROR 1:4-11: function call cannot be used in annotations [invalid-annotation]
# ty: Function calls are not allowed in type expressions (invalid-type-form) [Ln 1, Col 4]

Are you saying this is changed in 3.15? Or am I misunderstanding something?


References:

ty playground
mypy playground
pyright playground
pyrefly playground

1 Like

Sorry Sayandip, you are right - I was thinking about the runtime behavior.

So, your question is valid: What should type checkers implement when it comes to function calls as type annotations?

This is an interesting idea I hadn’t seen before, thank you!

As the previous commenters mentioned, this would be a novel use of call expressions in type expressions. It’s allowed by the grammar, but some type checkers may find it difficult to support call expressions in this context.

Some specific points:

  • I feel keyword arguments should probably map to keyword-only parameters, just as positional arguments map to positional-only parameters. (That is, Callable(x=int)[object] would describe a callable with a single keyword-only parameter x of type int.) Keyword-only parameters are much more commonly useful in callback signatures.
  • It seems important to support optional parameters; keyword-only parameters are almost always optional in practice. A natural way to do that might be to use NotRequired. So for example, Callable(x=NotRequired[int]) would describe an optional, keyword-only parameter x of type int. This could work for positional parameters too.
  • This could also support *args typing: Callable(*Ts) (where Ts is a TypeVarTuple) and Callable(*tuple[int, ...]) (for *args of type int).
  • The placement of the return type looks a little weird to me, but I don’t have a better syntax suggestion.
6 Likes

I think the idea of the OP was to somewhat mimic the syntax for function definitions, which makes sense, but has to be incomplete due to the restrictions of Python’s syntax.

Currently, parameters specified using Callable are assumed to be position only according to specs. To extend this to optional parameters and keyword-parameters makes sense. Optional parameters and keyword-parameters are two orthogonal concepts in Python, the proposal however seems to lump them together.

Also, I am not completely sure how much I like Callable(int, option=str)[bool] since str would be the type, not the default value for the parameter option.

1 Like

I don’t think there’s any verbiage in the documentation that explicitly invalidates a call expression as a type annotation, even though there certainly is no precedent for it.

But yeah for type checkers to support this proposal they do have to special-case a call to typing.Callable/collections.abc.Callable as a valid type annotation while not generalizing it to other calls, which I think is reasonable because Callable in particular is used to validate calls so applying a call syntax to it actually feels intuitively readable (to me at least).

Ah yes I’m not sure what I was thinking but you’re absolutely correct that defaulting to keyword-only parameters makes so much more sense, just like how Callable[[int], Any] describes a callable with a single positional-only parameter of type int.

And a callable with a keyword-or-positional parameter such as def f(x: int) -> Any: ... should be deemed compatible with both Callable[[int], Any] and Callable(x=int)[Any].

Great suggestion to adapt NotRequired for declaring an argument as optional. Makes perfect sense. Thanks!

Great point about supporting variadic arguments with Callable(*tuple[int, ...]). Support for Callable(*Ts) has already been included in my original proposal by the way.

Similarly, I suppose we can support typed variadic keyword arguments in form of Callable(**dict[str, int]), noting that the keys must always be of type str.

Yeah it’s the best syntax I can think of without introducing a new grammar rule. It at least makes the return type visually separated from the types of the parameters, and the square brackets helps give visual cues to the fact that the expression is a type annotation overall rather than a regular call.

100% agreed as mentioned in my reply to Jelle.

Yeah this is unfortunately what happens when we try repurposing an existing syntax. I’m hoping that the fact that we are already used to constructing a dict with both {'a': 1} and dict(a=1) means that we may be able to quickly get used to translating Callable(int, option=str)[bool] into an imaginary Callable(int, option: str)[bool] in our mind.

3 Likes

Since the original intent of this proposal is to find a more ergonomic way to express a callable with typed keyword parameters inline, I’m now thinking about repurposing the slice notation instead:

Callable[[int, 'option': str], bool]

This has the obvious upside of being 100% compatible with the existing usage of Callable, with the downsides being that we can’t use bare names to specify keyword parameters[1] and that the ** operator isn’t available in this context to help simplify usage of ParamSpec.

What you do you guys think?


  1. …unless we do something more drastic like making the compiler special-case Callable to implicitly convert names in slice.start in any element of Callable[0] to a string. ↩︎

I’m not sure if I’m understanding the key-word only logic.

The most common situation where I need to anotate a function is where the function is an argument to a function, and inside that function it gets called in a particular manner. For example, using keywords. You seem to say that marking it as keyword only is ok, because keyword-allowing functions are a subtype of keyword only functions, and therefore if you specify

def f(g: KeywordOnlyFunction): ...

the type checkers will automatically allow

g: KeywordAllowedFunction
print(f(g))

?

I don’t find much value in it as compared to the original proposal. This is probably more consistent than the original proposal, but there is very little value being added here. I personally haven’t had the need to type keyword only parameter contract. In the one occasion I did, I used Protocol. If this is the only value being added here, I am personally fine with the status quo. IMHO, the subscription syntax has been stretched as far as possible, I don’t believe it will help extend Callable.

OTOH, although syntax of the original proposal is visually a little jarring (and I am still not sure what I ultimately think about it), it is definitely more capable. Regarding the ()[] situation, maybe something like the following would look less radical?:

func: Callable[ParamSpec(int, option=str), bool]

Where ParamSpec would be dispatched differently based on whether the first argument is a valid identifier LiteralStr. It is verbose, yes. But I hope, some day, PEP 677 will handle that. Here, if ParamSpec is constructed separately, there could be interesting possibilities with P.args and P.kwargs. This could well be confusing as well.


Now that I think about it, do you feel something akin to Callable(int, '/', '*', position=str)[bool] is too sacrilegious?

1 Like

That’s currently a syntax error. Something like this would be possible though:

Callable[[int], {'option': str}, bool]
2 Likes

After sleeping a night over it, I came up with an alternative: CallableLike, where you would provide a template

   def myTemplate(x: int, /, y: float = 0.0) -> None: ...
   
   def myHandler( callback: CallableLike[myTemplate] ): ...

This is not completely inline, but would allow for the use of the “natural” syntax. Here, CallableLike[myTemplate] would be equivalent to CallableLikeMyTemplate as defined by

    class CallableLikeMyTemplate(Protocol):
        @staticmethod
        def __call__(x: int, /, y: float = 0.0) -> None: ...

Since I did not find a link: A similar discussion is going on here.

Ouch I had a brain fart moment haha. It should really be something more like:

Callable[int, 'option': str][bool]

But at this point I’ve come to the realization that there may never be a perfect inline function-definition-like solution without a new grammar rule. Something’s just gotta give. :sweat_smile:

Yeah this would save a line over a callback protocol but I was really hoping for something inline.

1 Like

Yeah that looks less radical but is indeed too verbose for me. If only we could find more use cases for the new grammar introduced by PEP-677 it may just see the light of day someday.

I’ve thought about such sentinels too. It’s probably the most capable inline solution but just not very pretty. I can live with it though. Let’s see if more people like it.

1 Like

For what it’s worth, I think the Callable(...params)[return-type] syntax is far more readable. The nested brackets for parameter types makes it so hard to parse visually.

1 Like