Pragmatic type checker defaults for untyped decorators

Hi. I’ve been pondering about the problem of type checking in presence of untyped decorators and thought that it might be a good idea to surface it here to learn how other type checker (in particular the oncoming ty and pyrefly) maintainers see it.

Fully type hinted decorators are more or less straightforward to support, it’s just another syntax for a function call. However, consider the following trivial untyped decorator and how type checkers process it:

import functools
from typing import reveal_type


def untyped(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)

    return wrapper


@untyped
def f(n: int) -> str:
    return str(n)


reveal_type(f)  # Mypy: Any; Pyright: (n: int) -> str
f("bar", 42)  # Mypy: no error; Pyright: expects one positional argument

As far as I see, though I haven’t delved into the implementations, Mypy and pyright are at the opposite ends of a spectrum with regards to untyped decorators. Mypy conservatively considers that an untyped decorator turns a decorated function into Any, while Pyright assumes that such a decorator doesn’t change the original type of a decorated function.

From gradual typing point of view, Mypy approach is better, as it doesn’t trigger false positives in working non-annotated code (if untyped did change a signature). However, from practicality point of view, pyright approach seems more sensible, since it appears that most of the decorators in the wild are “transparent” or “safe”, i.e. they don’t modify the signature of the decorated function, just surround its invocation in some “before-after” logic.

In PyCharm, we historically leaned towards the more conservative Mypy-like approach, considering untyped decorators as potentially modifying a function in an arbitrary way and hence suppressed all warnings for decorated functions. However, in 2025 it seems that the type system is popular and expressive enough so that it’s reasonable to ask users to properly type hint a minority of such “magical” “unsafe” decorators or at least declare them as returning an explicit Any or Callable[..., Any] for a few too complex cases.

In our issue tracker we have issues for both false positives (mostly in the past) and false negatives caused by untyped decorators. Also, for LSP, inferring Any also effectively disables code completion on a decorated function call result. I don’t particularly like the idea of forcing people, especially language newcomers, to use type hints to suppress type checker warnings, so it made me think if there could be a good practical middle ground.

Overall, it reminded me of a problem of processing context managers optionally suppressing exceptions. The chosen heuristic for the return type of __exit__ being strictly bool and not bool | None as an indicator of suppressing decorators actually works well for untyped code just as well.

class NotSuppressing:
    def __enter__(self):
        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        pass


class Suppressing:
    def __enter__(self):
        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        return issubclass(exc_type, RuntimeError)


def might_raise():
    pass


def f():
    with Suppressing():
        might_raise()
        return
    print("Reachable")  # ok


def g():
    with NotSuppressing():
        might_raise()
        return
    print("Reachable")  # warning

Wouldn’t it possible to come up with a similar common policy when type checkers can assume that an untyped decorator is “safe”, i.e. that we can treat

def untyped(func):

as

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

or

def untyped[T](func: T) -> T:

when processing a decorated function.

One idea is relying on presence of @functools.wraps in a decorator’s body, i.e. if on all normal exit paths it returns a wrapper function decorated with functools.wraps(fn), where fn is the decorator’s own first argument, or another function which returns such a wrapper (for decorators with arguments), we can assume that it doesn’t change the decorated function, otherwise consider that it turns a decorated function type into Any. Of course, it’s fragile and won’t cover many cases of returning a lambda or the original parameter directly conditionally, etc. but it seems that for the majority of simple decorators it should be fine and it follows the existing good practice of using functools.wraps in decorators.

What do you think of it? Sorry if it was discussed previously. I’d be glad if you pointed me to relevant topics.

1 Like

You have to be careful with this. functools.wraps is actually a decorator factory that takes parameters for which things it passes on, and decorators that modify the effective type of their wrapped function can still use it accurately.

With that said, specifying that typecheckers may have their own heuristic for this causes problems. Exported types in library code may then differ in type depending on their user’s type checker.

mypy treating untyped code as returning Any seems correct to me given the gradual type system

Pyright’s behavior is a little more nuanced than what you describe. It uses the inferred return type of an unannotated decorator as long as the resulting type contains no unknowns. If the inferred return type is Unknown or partially unknown, it then assumes that the decorator doesn’t change the original type of the decorated function.

As with many differences between pyright and mypy, this one is driven by divergent design goals. Pyright was designed to be the foundation for a language server whereas mypy was not. If a type checker is driving language server behaviors, it’s important not to change untyped decorators into an Any because that significantly harms the developer experience. If a decorated function becomes Any, all of the following stop working: completion suggestions, signature help, hover text, docstrings, deprecations, and more.

I don’t think the typing spec should mandate any specific behavior in this case because it’s reasonable for different tools to make different decisions based on their design goals.

Interestingly, mypy’s behavior is inconsistent between function and class decorators. For class decorators, it always assumes that a class decorator doesn’t modify its target. This might just be a missing feature rather than an intentional design decision. It appears that the latest versions of pyrefly and ty likewise ignore the effects of class decorators.

For function decorators, it appears that the most recent version of pyrefly infers the return type of the decorator and applies it regardless of whether it’s fully or partially unknown. That’s the approach I took initially with pyright, but I changed it based on strong feedback from language server (pylance) users. Pyrefly is also designed to be the foundation for a language server, so it’s likely that they’ll received similar feedback. It will be interesting to see how they resolve this.

It will also be interesting to see what ty chooses to do because it leans heavily into the “gradual guarantee” but also aims to be the basis for a language server. This seemingly creates competing goals in situations like this.

1 Like

I don’t agree here. I think the spec should mandate a behavior. It’s fine for tools that aren’t type checkers to do other things locally, but the specification should be something library authors can refer to and result in consistent behavior for their users, no matter which type checker they use.

I agree with what Liz wrote, but with slightly more nuance here.

I don’t see these as competing goals at all, I think it just indicates that language servers need to use more context than just the type system type in a gradually typed language. It would be possible to track what operation introduced Unkown types and pass that to a language server so that type checking remains consistent, but in the face of specific operations, the language server can still provide it’s best guess at a useful code suggestion.

2 Likes

On this specifically — I don’t think this is what we want our final behaviour to be in ty, but it also isn’t a huge priority for us to fix it right now given the number of other things we have to do :grinning_face_with_smiling_eyes:

+1 to this, I don’t think the typing spec should mandate that type checkers handle untyped decorators in any specific way, given that there are multiple reasonable choices here.

Re: pyrefly - our support for decorators is also incomplete. We have a TODO to stop ignoring class decorators: pyrefly/pyrefly/lib/alt/expr.rs at b17eb0466f480adf2f618d7b5e1244c645ee4d5c · facebook/pyrefly · GitHub XD

1 Like

Thanks for raising this question, @east825 . It’s not a problem that we’ve spent a lot of time working on yet in ty.

Personally the approach I find most appealing is to preserve graduality (and avoid false positives) in the type system and type diagnostics, but find technical solutions internally to preserve “best guess” context that’s useful to an LSP, as alluded to by @mikeshardmind above. Practically what I’d imagine this to look like in ty is that we would consider a function decorated by an untyped decorator to be Any, but internally we’d track a different form of Any that wraps a “best guess type”, so we could still give LSP suggestions based on knowing what function was wrapped by the untyped decorator, and potentially track the return values of calls to such a function as also having a “best guess” type. But we wouldn’t emit (potentially false positive) type diagnostics based on such “best guess” types, and we would only preserve them at all on a best-effort basis, in the cases where it has the most LSP impact.

(But at this point this is just my opinion; it’s not something we’ve settled on as a team, or tried to implement yet.)

6 Likes

@mikeshardmind

You have to be careful with this. functools.wraps is actually a decorator factory that takes parameters for which things it passes on, and decorators that modify the effective type of their wrapped function can still use it accurately.

Agree. But I meant the most basic scenario when functools.wraps accepts only the wrapped function as its sole argument. In this case it most likely means that the wrapper function is intended to fully replicated the signature of the wrapped one.

@erictraut Thanks for clarifying the pyright’s policy in this regard. It didn’t occur to me that there might be a difference between

def untyped(func):
    def wrapper(extra, *args, **kwargs):
        return func(*args, **kwargs)

    return wrapper

@untyped
def f(n: int) -> str:
    return str(n)

f("foo", 42)  # false positive
f(42)  # false negative

and

from typing import Any

def untyped(func):
    def wrapper(extra: Any, *args: Any, **kwargs: Any):
        return func(*args, **kwargs)

    return wrapper

@untyped
def f(n: int) -> str:
    return str(n)

f("foo", 42)
f(42)  # false negative

but turns out there is. This is subtle and still effectively means that if there is a decorator mutating a signature, type hints must be added somewhere to make type checker aware of it, if not on the decorator itself then at least on the wrapper function. But as I said, with the current state of typing, it makes a lot of sense (though violates the gradual guarantee sometimes).

@carljm I have to admit that I’m also very curious about how ty tackles the problem eventually :slight_smile:

Your idea reminded me of one (I thought mostly legacy) concept we have in PyCharm.
Initially we didn’t handle union types the way they are treated by all other type checkers and are defined in the type theory. Namely, we considered that a union type was compatible with another type if any of its member types was compatible with that type. In the same way, it was allowed to complete and access an attribute defined on any of its member types. Internally we call this semantics “weak unions”. It was obviously unsound, after all one could pass int | str where str was expected, but it was an intentional design decision because back then there were no function overloads and type narrowing facilities were very limited, so having proper “strict”, or rather “normal”, union types would have caused a lot of false positives. This trade-off was discussed in Mypy issue tracker at one point. We are now gradually transitioning towards proper “strict” unions but weak unions introduced one useful loophole in the type system from LSP point of view. A “weak” union of Any with another type T is considered compatible with any other type, just like Any itself, so it doesn’t trigger any false positives, yet it offers code completion, navigation and other features of its “known” members. We relied on such “weak” types in many places to work around APIs that couldn’t be properly type hinted or tricky to analyse (for example, we infer a “weak” int for x + 1 where type type of x is unknown, since there is int.__radd__ returning int).

Recalling your last year’s talk at the Typing Summit, I have been wondering if this “weak type” semantics could be emulated to some extent with an intersection of Any with another type. Getting back to untyped decorators, it would mean that a type checker could infer an intersection of the original function type and Any for such decorated functions. But, on the other hand, it seems that propagating such intersections might cause some issues down the line during type inference. In particular, if such an inferred intersection ends up in the “expected” or supertype position, for instance during constraint solving. So probably it’s better to keep such “Any with a best guess” types separate and don’t mix them with intersections. But overall a “weak” type turned out to be a very useful general concept for the keeping the gradual guarantee, if used sparingly, and it seems very close to what you’re envisioning.

BTW thanks everyone for commenting on the topic!