ty icon indicating copy to clipboard operation
ty copied to clipboard

Consider assuming that untyped decorators pass through the signature unchanged

Open afonsotrepa opened this issue 1 month ago • 9 comments

Summary

I ran into this problem where ty wouldn't catch a mistyped return type but pyright would. Narrowed it down to a decorator I'm using. Sorry if this is a known issue (a quick search didn't find another open issue for this) or limitation.

Example

def deco(_):
    def inner(func):
        return func
    return inner


@deco(0)
def f() -> str:
    return ""


def g() -> float:
    return f()

Expected to get "error[invalid-return-type]" (which is indeed what we see if we remove the decorator) but instead got all checks passing.

Version

0.0.2

P.S.: Still loving ty and ruff, keep up the good work!

afonsotrepa avatar Dec 17 '25 02:12 afonsotrepa

Thank you for reporting this.

We currently don't support return type inference yet (which is why the return type of deco is just Unknown), but plan to add it soon. See #128 for details.

Note: It's actually not completely clear to me how other type checkers handle this case. It seems to me like we would have to infer a generic ∀ T. Callable[[T], T] type for inner here (and, in effect, for the return type of deco)? But pyright simply infers the type of deco as (_: Unknown) -> ((func: Unknown) -> Unknown). So it looks like it might use additional heuristics (something like: "if the return type of a decorator is Unknown, just pretend it acts like an identity operation")?

sharkdp avatar Dec 17 '25 08:12 sharkdp

Yes, I think I remember some discussion of this. At least pyright (maybe some other type checkers too) do make the assumption that an untyped decorator doesn't change the signature. I'm not sold on this idea, though I can see why it's attractive.

carljm avatar Dec 18 '25 00:12 carljm

I'm not sure this is really blocked by #128, since I don't think #128 will solve it, nor is necessary to "solve" it in the way that other type checkers do.

carljm avatar Dec 18 '25 00:12 carljm

Actually this issue is still present if we type hint the decorator (and this was my original issue, I was just trying to create a minimalist example but clearly cut too much out, sorry):

from collections.abc import Callable

def deco[**P, R](_: object) -> Callable[[Callable[P, R]], Callable[P, R]]:
    def inner(func: Callable[P, R]) -> Callable[P, R]:
        return func
    return inner


@deco(0)
def f() -> str:
    return ""


def g() -> float:
    return f()

Same deal, ty (0.0.2) says all checks passed while pyright (1.1.407) correctly detects a "reportReturnType" (Type "str" is not assignable to return type "float") error.

afonsotrepa avatar Dec 18 '25 01:12 afonsotrepa

@afonsotrepa Thanks for the example! It looks like that is a different limitation: https://github.com/astral-sh/ty/issues/1136

carljm avatar Dec 18 '25 15:12 carljm

@afonsotrepa note also that this version works in all type checkers (including ty) today, because it doesn't rely on the special-cased heuristic in #1136:

from typing import Callable, Protocol

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

def deco(_: object) -> Identity:
    def inner[**P, R](func: Callable[P, R]) -> Callable[P, R]:
        return func
    return inner

@deco(0)
def f() -> str:
    return ""


def g() -> float:
    return f()

carljm avatar Dec 18 '25 15:12 carljm

@carljm your Identity class fixed my issue, thanks!

afonsotrepa avatar Dec 18 '25 17:12 afonsotrepa

We could also do this in a way that is LSP-specific, by implementing some form of "best-guess types" that we won't use to emit type diagnostics, but will use for LSP purposes.

carljm avatar Dec 18 '25 21:12 carljm

@carljm sorry for another "off-topic" comment but I found your Identity class was not working for generic functions when using functools.wraps, so instead I'm now using:

def deco[**P, R](_: object) -> Callable[[Callable[P, R]], Callable[P, R]]:

    def dec(func: Callable[P, R]) -> Callable[P, R]:

        @functools.wraps(func)
        def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
                return func(*args, **kwargs)

        return wrapper

    return dec

This works with ty and pyright and successfully catches invalid return types, even for functions with generics. Hopefully this will be useful for someone else too.

afonsotrepa avatar Dec 18 '25 23:12 afonsotrepa