Parametrizing **P with [*Ts]

As per PEP 646, Callable supports unpacking a TypeVarTuple in its parameter list to capture positional argument types. This functionality seems to be lost when we alias a callable using **P:

from collections import abc
from typing import Any

type CoroFunc[**P, T] = abc.Callable[P, abc.Coroutine[Any, Any, T]]


def accepts_func[*ArgsTs](
    func: abc.Callable[[object, *ArgsTs], abc.Coroutine[Any, Any, object]],
    func2: CoroFunc[[object, *ArgsTs], object],  # error: TypeVarTuple is not allowed in this context
) -> None: ...

[pyright], [mypy]
Is this an underspecification, or is there a good reason a **P cannot be parametrized with [*Ts]?
I feel like this should be possible.

PEP 646 (which introduced TypeVarTuple) specifically allows an unpacked TypeVarTuple to parameterize the Callable special form. It doesn’t provide a similar special case for a ParamSpec specialization.

I agree that this would be theoretically possible, but I’d personally want to see a good justification for adding this. Both ParamSpec and TypeVarTuple are very complex features on their own (it took over two years to get support for each of these in mypy, and it was a heavy lift to add them to pyright), so my intuition is that adding support for the composition of the two would be nontrivial to support and work out all of the edge cases, error conditions, etc.

From a user perspective, I think that it makes a lot of sense that ParamSpec should always behave in the same way (when used correctly). Only allowing [*Ts] within Callable types does not solve any problem, comes at the cost of an additional assumption in the spec, and would be considered unexpected behaviour for most users.

But I also understand that implementing this behaviour in type-checkers will require a significant amount of effort, and I agree that implementation complexity is something that we should consider.

Reducing the complexity of the type-system itself (e.g. by removing redundant limitations like this one) should be prioritized over the engineering complexity. Put differently, I think we should prioritize the “user interface” over the “backend” (but not ignore it).

So I suppose it’s fair to ask for a couple of real-world use-case before jumping on board. As far as I’m concerned, the example in the original post is already pretty convincing, as it’s something that’s likely to be encountered in the wild.

I believe the typing spec pictures a Protocol defining __call__ as a more general version of Callable:

class Callable[**P, T](Protocol):
    def __call__(self, *args: P.args, **kwargs: P.kwargs) -> T: ...

Such protocol should be interchangeable with abc.Callable. [*Ts] breaks this assumption; to my knowledge you cannot create a protocol that supports both **P and *Ts, you have to pick either ahead of time [1].
This causes a disparity where a callable protocol is a superset of abc.Callable [2], except for TypeVarTuples where you have to use abc.Callable.


  1. You cannot even overload it, unless you’re willing to have a protocol with [**P, *Ts, T], and always have redundant type variables to specialize. ↩︎

  2. overloads, *args, **kwargs, named parameters ↩︎

1 Like