2025, Nov 26 11:00
Wrapping generic Python functions without losing types: why mypy/pyright infer Never and a descriptor workaround
Learn why decorating generic T->T functions in Python collapses to Never in mypy and pyright, and how to retain types with a descriptor workaround and tips.
Wrapping a generic function in Python without losing its type: what goes wrong and how to live with it
Turning a function of type T → T into a zero-arg thunk that returns a holder for later execution sounds straightforward. The shape is simple: accept a callable, keep it in a container, call it when you need to. The trouble starts when the callable is itself generic. Type checkers like mypy and pyright do not preserve the type variable in this setup, and the result collapses to Never. Let’s unpack why that happens and what you can do today.
The setup
Below is a minimal implementation. A small wrapper accepts a function of type T → T and returns a parameterless function producing a container that stores the original function.
from collections.abc import Callable
class Hold[U]:
def __init__(self, stored: Callable[[U], U]):
self.stored = stored
def packer[U](fn: Callable[[U], U]) -> Callable[[], Hold[U]]:
def make() -> Hold[U]:
return Hold(fn)
return make
Used with a non-generic function, this behaves as expected with static type checkers.
@packer
def echo_str(arg: str) -> str:
return arg
reveal_type(echo_str)
reveal_type(echo_str())
reveal_type(echo_str().stored)
reveal_type(echo_str().stored("test"))
But when applied to a generic function, the type argument disappears and the whole chain is inferred as Never.
@packer
def echo_generic[T](arg: T) -> T:
return arg
reveal_type(echo_generic)
reveal_type(echo_generic())
reveal_type(echo_generic().stored)
reveal_type(echo_generic().stored("test"))
One would reasonably expect echo_generic to be typed as def[T]() -> Hold[T], echo_generic().stored as def[T](T) -> T, and the last call to resolve to str. That is not what mypy or pyright do today.
What’s actually happening
The annotations attempt to reuse the same symbol T across different type variable scopes. Generic functions and generic classes bind type variables inside their own bodies. Module-level variables, however, cannot carry a free type variable. In this case echo_generic is a module-scoped variable after decoration, and a type like Hold[T] at the module scope is not satisfiable, because there is no binder for T at that scope.
That is why the type checker ends up with Hold[Never]. It represents a case that cannot be parameterized by a concrete type at the module level. This ties directly to the difference between def (T) -> T for some T and def[T](T) -> T for all T. These forms are fundamentally different and there is no single interface that unifies them in Python’s type system. It is also an early vs late binding issue. When the decorator is applied, its type variables must be resolved, and current type checkers do not carry generic functions through that stage. The specification does not mandate such behavior.
A pragmatic workaround with a descriptor
If a concrete type is available at the module scope, the stored callable should accept and return exactly that concrete type. If it is not available, the best you can do is expose a callable that returns whatever it receives. This distinction can be modeled via a descriptor that changes the type of the stored attribute depending on whether Hold is parameterized by a concrete type or not.
The following pattern encodes that behavior. It relies on TYPE_CHECKING-only machinery and overloads to guide the checker, without altering runtime behavior. Note that this is a hack and hinges on the current inference of Hold[Never]. If a checker starts inferring Hold[Any] instead, it will not work as intended. It also does not propagate bounds or constraints on type variables; they become unconstrained in the generic callable form.
from collections.abc import Callable
from typing import TYPE_CHECKING, Any, Never, Self, overload
if TYPE_CHECKING:
class StoredView:
@overload # type: ignore[no-overload-impl]
def __get__(self, instance: None, owner: type[object], /) -> Self: ...
@overload
def __get__[R](
self, instance: Hold[Never], owner: type[Hold[Never]], /
) -> Callable[[R], R]:
"""
No concrete parameterization available; return a callable that
simply returns the same type it receives
"""
@overload
def __get__[U](
self, instance: Hold[U], owner: type[Hold[U]], /
) -> Callable[[U], U]:
"""
Concrete parameterization present; return a callable constrained
to that concrete type
"""
def __set__[U](
self, instance: Hold[Any], value: Callable[[U], U], /
) -> None: ...
class Hold[U]:
if TYPE_CHECKING:
stored = StoredView()
def __init__(self, stored: Callable[[U], U]):
self.stored = stored
def packer[U](fn: Callable[[U], U]) -> Callable[[], Hold[U]]:
def make() -> Hold[U]:
return Hold(fn)
return make
@packer
def echo_str(arg: str) -> str:
return arg
reveal_type(echo_str)
reveal_type(echo_str())
reveal_type(echo_str().stored)
reveal_type(echo_str().stored("test"))
reveal_type(echo_str().stored(1))
@packer
def echo_generic[T](arg: T) -> T:
return arg
reveal_type(echo_generic)
reveal_type(echo_generic())
reveal_type(echo_generic().stored)
reveal_type(echo_generic().stored("test"))
reveal_type(echo_generic().stored(1))
With this setup, when a concrete type is available, stored is typed as a callable that accepts and returns precisely that type. When no concrete type is available and the container sits at Hold[Never], stored is exposed as a generic callable def[R](R) -> R.
Why this matters
Decorators and higher-order utilities that deal with T → T functions are common in Python codebases. Without understanding how type variable scopes work, it is easy to unintentionally erase generics and lose type safety or end up with Never in critical places. The difference between existential and universal quantification shows up in practice: def (T) -> T for some T is not the same as def[T](T) -> T for all T, and the type system enforces that asymmetry at module boundaries.
Takeaways
Expect type variables to be bound only inside generic classes and functions, not at the module scope. Do not assume a decorator can preserve a free type variable when turning a generic function into a zero-arg thunk that produces a container. If you need to preserve useful typing at the attribute access site, a descriptor-based workaround can separate the two cases: when a concrete type is known and when it is not. This approach is explicitly hacky and may depend on current inference details like Hold[Never], and it does not preserve bounds or constraints on type variables. If you can redesign the API to keep the type variable bound in a function parameter list, you avoid the early binding problem entirely. Otherwise, use the descriptor trick consciously and document the trade-offs.