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.