Skip to content

Making a type alias of a Callable that describes a decorator discards all type information #18842

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
ascopes opened this issue Mar 26, 2025 · 11 comments
Labels
bug mypy got something wrong topic-type-alias TypeAlias and other type alias issues

Comments

@ascopes
Copy link

ascopes commented Mar 26, 2025

Bug Report

Attempting to make a type alias for a decorator results in MyPy losing type information.

To Reproduce

from typing import *

type Fn[**P, R] = Callable[P, R]
type Decorator[**P, R] = Callable[[Fn[P, R]], Fn[P, R]]


def do_the_thing[**P, R]() -> Decorator[P, R]:
    def decorator(fn: Fn[P, R]) -> Fn[P, R]:
        return fn
    return decorator


@do_the_thing()
def blahblah() -> None:
    pass

Seems this is not an artifact of how variance is being calculated with the new type alias syntax as using the old style syntax has the same issue when declaring the type hints...

from typing import *

P = ParamSpec("P")
R_co = TypeVar("R_co", covariant=True)
Fn: TypeAlias = Callable[P, R_co]
Decorator: TypeAlias = Callable[[Fn[P, R_co]], Fn[P, R_co]]


def do_the_thing[**P, R_co]() -> Decorator[P, R_co]:
    def decorator(fn: Fn[P, R_co]) -> Fn[P, R_co]:
        return fn
    return decorator


@do_the_thing()
def blahblah() -> None:
    pass

...and the same issue if I just discard the type variables entirely in the function definition:

from typing import *

P = ParamSpec("P")
R_co = TypeVar("R_co", covariant=True)
Fn: TypeAlias = Callable[P, R_co]
Decorator: TypeAlias = Callable[[Fn[P, R_co]], Fn[P, R_co]]

def do_the_thing() -> Decorator[P, R_co]:
    def decorator(fn: Fn[P, R_co]) -> Fn[P, R_co]:
        return fn
    return decorator

@do_the_thing()
def blahblah() -> None:
    pass

Using a protocol doesn't work either:

from typing import *

type Fn[**P, R] = Callable[P, R]

class Decorator[**P, R](Protocol):
    def __call__(self, fn: Fn[P, R], /) -> Fn[P, R]: ...

def do_the_thing[**P, R]() -> Decorator[P, R]:
    def decorator(fn: Fn[P, R]) -> Fn[P, R]:
        return fn
    return decorator

@do_the_thing()
def blahblah() -> None:
    pass

...so it seems the type information is just totally discarded.

Strangely, if I don't type-alias the decorator, then it works fine (which is a workaround, but very annoying and verbose for more complicated logic).

# this is fine.
def do_the_thing[**P, R]() -> Callable[[Fn[P, R]], Fn[P, R]]:
    def decorator(fn: Fn[P, R]) -> Fn[P, R]:
        return fn
    return decorator

The problem is that I want to be able to alias Callable[[Fn[P, R]], Fn[P, R]] to a clean single identifier that I can reuse in the current type context, but I cannot find a nice way of doing that at the moment.

Expected Behavior

This should be valid.

Actual Behavior

main.py:14: error: Argument 1 has incompatible type "Callable[[], None]"; expected "Callable[[VarArg(Never), KwArg(Never)], Never]"  [arg-type]
Found 1 error in 1 file (checked 1 source file)

Related Issues:

I came across GH-16512, but that was closed as fixed. Not sure if it is the same problem or not!

Your Environment

  • Mypy version used: 1.14.1, 1.15.0
  • Mypy command-line flags: none
  • Mypy configuration options from mypy.ini (and other config files): none
  • Python version used: 3.12, 3.13
  • Reproducible in https://mypy-play.net/?mypy=1.15.0&python=3.13
@ascopes ascopes added the bug mypy got something wrong label Mar 26, 2025
@brianschubert brianschubert added topic-paramspec PEP 612, ParamSpec, Concatenate topic-type-context Type context / bidirectional inference and removed topic-type-context Type context / bidirectional inference labels Mar 27, 2025
@sterliakov
Copy link
Collaborator

sterliakov commented Mar 27, 2025

I'm not sure whether this is a bug or intended behavior. Let's look at the decorator:

@deco()
def fn() -> None: ...

# is the same as

deco_callable = deco()  # We don't know the type here - neither P nor R appeared so far!

def fn() -> None: ...

fn = deco_callable(fn)  # So we fail here - P and R should've already been bound, but there is not enough info beforehand

Your Protocol approach is almost there. If you move the type vars to the appropriate scope (only decorator is semantically generic, not Decorator), it works (playground):

from typing import *

type Fn[**P, R] = Callable[P, R]

class Decorator(Protocol):
    def __call__[**P, R](self, fn: Fn[P, R], /) -> Fn[P, R]: ...

def do_the_thing() -> Decorator:
    def decorator[**P, R](fn: Fn[P, R]) -> Fn[P, R]:
        return fn
    return decorator

@do_the_thing()
def blahblah(x: str) -> None:
    pass

reveal_type(blahblah)  # N: Revealed type is "def (x: builtins.str)"

It isn't correct to say that a Decorator instance is generic - it can be applied to any callable, not to some specific one.

I'm not sure how to express that without protocol, unfortunately - let's wait for someone else to comment on that? There doesn't seem to be any way to propagate generics like you want, you need

type Decorator = Callable[[Fn[P, R]], Fn[P, R]]

so that it isn't generic itself, but binds typevars internally. I doubt it can be expressed without an explicit Protocol.

@erictraut
Copy link

I'm not sure whether this is a bug or intended behavior.

Looking at this from the perspective of the typing spec, I think the code sample in the original repro should type check without error. As the OP notes, it type checks fine if you manually expand the type alias. Using a type alias shouldn't change the behavior. For comparison, it type checks fine in pyright.

@sterliakov
Copy link
Collaborator

sterliakov commented Mar 27, 2025

perspective of the typing spec

Do I understand correctly that the spec requires that either both versions pass or they are both rejected, only mandating consistency? I can't find anything about such typevar propagation in Callable there.

In other words, according to the spec, does def fn[T]() -> Callable[[Callable[..., T]], Callable[..., T]] bind T early? Is fn() a Callable[[Callable[..., Unknown]], Callable[..., Unknown]] or a Callable[[Callable[..., T]], Callable[..., T]]?

And how about def fn[T](x: T) -> Callable[[Callable[..., T]], Callable[..., T]] - is the return type generic or bound to T from fn invocation?

@erictraut
Copy link

Yeah, typevar binding for Callable is underspecified in the typing spec. (That's probably something we should fix.) I know that mypy and pyright both special-case the Callable special form when it is used within a return type annotation for a generic function.

The typing spec is less ambiguous (although admittedly not as clear as it could be) regarding type aliases. A type alias substitution shouldn't change the behavior. I think that means both versions should either pass or fail.

@sterliakov
Copy link
Collaborator

Yes, I agree that aliases should be treated in the same way, this part is indeed a bug - snippets with and without alias should be equivalent.

I learned only recently that the version without an alias works at all - IMO it's just wrong or at least ill-defined, while the explicit Protocol is an unambiguous solution. Are there any cases where a long-ish Callable solves a problem that a separate Protocol with explicit generics scope can't? (I never encountered one, but might be missing something)

@erictraut
Copy link

Are there any cases where a long-ish Callable solves a problem that a separate Protocol with explicit generics scope can't?

I can't think of any off the top of my head. However, there's unfortunately quite a bit of code that relies on the special-case behavior that was initially introduced by mypy (presumably before Protocol was even introduced to the type system). I replicated this in pyright for compatibility reasons even though I would have preferred not to. If we were to clean this up in the typing spec and mandate the use of Protocol for deferred type var binding, it would create pain for some developers. Maybe it's still worth it, but we'd probably need a lengthy deprecation plan.

@sterliakov sterliakov added topic-type-alias TypeAlias and other type alias issues topic-calls Function calls, *args, **kwargs, defaults and removed topic-paramspec PEP 612, ParamSpec, Concatenate topic-calls Function calls, *args, **kwargs, defaults labels Mar 29, 2025
mikeshardmind added a commit to mikeshardmind/async-utils that referenced this issue Apr 6, 2025
@mikeshardmind
Copy link

mikeshardmind commented Apr 6, 2025

Are there any cases where a long-ish Callable solves a problem that a separate Protocol with explicit generics scope can't?

Protocols in their current form require explicitly importing typing and have runtime evaluation costs, type alias statements are deferred and can be written in a way that the expression is never evaluated unless introspected.

Outside of that, they're also a lot more verbose without a reasonable reason, especially in the case of paramspec based callable declarations:

# Use below doesn't accept non-task Futures, so can't accept general awaitables
type CoroFunc[**P, R] = Callable[P, Coroutine[t.Any, t.Any, R]]
type TaskFunc[**P, R] = Callable[P, asyncio.Task[R]]
type TaskCoroFunc[**P, R] = CoroFunc[P, R] | TaskFunc[P, R]

type Deco[**P, R] = Callable[[TaskCoroFunc[P, R]], TaskFunc[P, R]]

def taskcache[**P, R](
    ttl: float | None = None,
    *,
    cache_transform: CacheTransformer | None = None,
) -> Deco[P, R]:

The equivalent written with protocols and without type aliases doesn't have a meaningful difference:

class CoroFunc[**P, R](Protocol):
    def __call__(self, *args: P.args, **kwargs: P.kwargs) -> Coroutine[t.Any, t.Any, R]:
        ...

class TaskFunc[**P, R](Protocol):
    def __call__(self, *args: P.args, **kwargs: P.kwargs) -> asyncio.Task[R]:
        ...

class Deco[**P, R](Protocol):
    def __call__(self, f: CoroFunc[P, R] | TaskFunc[P, R], /) -> TaskFunc[P, R]:
        ...

def taskcache[**P, R](
     ttl: float | None = None,
    *,
    cache_transform: CacheTransformer | None = None,
)  -> Deco[P, R]:

(I've copy pasted this from an existing codebase, CacheTransformer's declaration is left out, but irrelevant here other than that it would show even more verbosity)

I don't see any reason the examples here should fail, or why we should force this to require protocols

@ascopes
Copy link
Author

ascopes commented Apr 6, 2025

@mikeshardmind does that mean this is likely definitely a bug in MyPy as opposed to an issue with how I am approaching this?

@mikeshardmind
Copy link

mikeshardmind commented Apr 6, 2025

@ascopes I consider this a bug in mypy. As Eric pointed out above:

A type alias substitution shouldn't change the behavior. I think that means both versions should either pass or fail.

If you can think of a reason why the two of my examples should not be seen as equivalent to a type checker, or why the latter should fail, then there's a potential argument that there's specification clarity at play, however I don't believe Paramspecs are unclear about their use in Callables or in Protocols currently.

@sterliakov
Copy link
Collaborator

We failed to expand the alias somewhere - yes, it is a bug, aliases (at least non-recursive) and their expansions should be completely equivalent.

But also we aren't required to support pushing unsolvable type variables to the Callable in the return type (even though it's currently implemented). The spec does not mention this scenario, and my common sense does not require it either (I even think it should not work this way, but it's been this way for a long time, so I agree with Eric that it would be difficult to axe entirely).

@sterliakov
Copy link
Collaborator

Btw, #18877 answers my question about "something doable with Callable but not with Protocol". Callables are assumed to undergo method binding, while Protocols are not. Mypy and pyright agree on that now.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug mypy got something wrong topic-type-alias TypeAlias and other type alias issues
Projects
None yet
Development

No branches or pull requests

5 participants