2025, Nov 27 13:00

Type-safe Python decorator factory: preserve function signatures with ParamSpec and overloads for zero-arg or matching-arg hooks

Build a Python decorator factory that preserves function signatures. Use ParamSpec and overloads to support zero-arg and matching-signature hooks error-free.

Decorators that preserve a function’s signature are straightforward to type until you need a single factory that supports two incompatible patterns. One case expects a helper callable with the exact same signature as the wrapped function; the other expects a zero-argument callable. At runtime you can branch, but a static type checker won’t accept a naive union because the underlying type variables stand for different things in each case.

Problem statement

Consider two decorator factories. The first accepts a callable with the same signature as the function being wrapped and forwards the arguments to it before executing the wrapped function. The second accepts a zero-argument callable and invokes it unconditionally. Both work and type-check when used separately.

import inspect
from typing import Any, Callable, ParamSpec, TypeVar

PRMS = ParamSpec("PRMS")
R = TypeVar("R")


def hook_with_params(cb: Callable[PRMS, Any]) -> Callable[[Callable[PRMS, R]], Callable[PRMS, R]]:
    def _decor(fn: Callable[PRMS, R]) -> Callable[PRMS, R]:
        def _inner(*args: PRMS.args, **kwargs: PRMS.kwargs) -> R:
            cb(*args, **kwargs)
            return fn(*args, **kwargs)
        return _inner
    return _decor


def trace_with_args(a: int, b: str) -> None:
    print(f"trace_with_args: a={a}, b={b}")


@hook_with_params(trace_with_args)
def handled1(a: int, b: str) -> None: ...


handled1(1, "test")  # prints "trace_with_args: a=1, b=test"
def hook_no_params(cb: Callable[[], Any]) -> Callable[[Callable[PRMS, R]], Callable[PRMS, R]]:
    def _decor(fn: Callable[PRMS, R]) -> Callable[PRMS, R]:
        def _inner(*args: PRMS.args, **kwargs: PRMS.kwargs) -> R:
            cb()
            return fn(*args, **kwargs)
        return _inner
    return _decor


def trace_no_args() -> None:
    print("trace_no_args")


@hook_no_params(trace_no_args)
def handled2(a: int, b: str) -> None: ...


handled2(1, "test")  # prints "trace_no_args"

Now try to combine both into one factory that inspects the callable at runtime. The logic is fine, but the type checker rejects it.

def fuse_hooks(
    cb: Callable[[], Any] | Callable[PRMS, Any],
) -> Callable[[Callable[PRMS, R]], Callable[PRMS, R]]:
    def _decor(fn: Callable[PRMS, R]) -> Callable[PRMS, R]:
        def _inner(*args: PRMS.args, **kwargs: PRMS.kwargs) -> R:
            if len(inspect.signature(cb).parameters):
                cb(*args, **kwargs)
            else:
                cb()  # pyright error: Arguments for ParamSpec "PRMS@fuse_hooks" are missing (reportCallIssue)
            return fn(*args, **kwargs)
        return _inner
    return _decor


@fuse_hooks(trace_with_args)
def with_trace1(a: int, b: str) -> None: ...


@fuse_hooks(trace_no_args)  # pyright error: Argument of type "(a: int, b: str) -> None" cannot be assigned to parameter of type "() -> T@fuse_hooks"
def with_trace2(a: int, b: str) -> None: ...


with_trace1(1, "test")  # prints "trace_with_args: a=1, b=test"
with_trace2(1, "test")  # prints "trace_no_args" but pyright error: Expected 0 positional arguments (reportCallIssue)

Why the union fails

Both factories return a decorator whose type is Callable[[Callable[PRMS, R]], Callable[PRMS, R]], but the role of PRMS is not the same in the two scenarios. In the first factory, PRMS ties the wrapped function’s signature to the helper callable and statically enforces that they match. In the second, PRMS only preserves the signature of the wrapped function, while the helper is zero-arg and unrelated. These are two distinct generic contexts. Trying to jam them into a single signature with a union makes the type variable serve two incompatible purposes, which a static checker can’t resolve.

Overloads separate the contexts

The fix is to provide two overloads for the decorator factory. Each overload defines PRMS in the correct context. The single implementation can then use a looser type for the helper callable and branch at runtime as before.

import collections.abc as cabc
import inspect
import typing as t

PRMS = t.ParamSpec("PRMS")
R = t.TypeVar("R")


@t.overload
def fuse_hooks(cb: cabc.Callable[[], t.Any], /) -> cabc.Callable[[cabc.Callable[PRMS, R]], cabc.Callable[PRMS, R]]:
    """
    Empty-parameters case, equivalent to the zero-arg factory.
    Produces a decorator that preserves the wrapped function's signature.
    """

@t.overload
def fuse_hooks(  # pyright: ignore[reportOverlappingOverload]
    cb: cabc.Callable[PRMS, t.Any], /
) -> cabc.Callable[[cabc.Callable[PRMS, R]], cabc.Callable[PRMS, R]]:
    """
    Non-empty-parameters case, equivalent to the matching-signature factory.
    Produces a decorator that checks and matches the wrapped function's signature against `cb`.
    The pyright ignore mirrors a bug also seen in mypy; in practice these signatures don't overlap.
    """

def fuse_hooks(  # pyright: ignore[reportInconsistentOverload]
    cb: cabc.Callable[..., t.Any],
) -> cabc.Callable[[cabc.Callable[PRMS, R]], cabc.Callable[PRMS, R]]:
    """
    Loosely typed implementation for ergonomics.
    """
    def _decor(fn: cabc.Callable[PRMS, R]) -> cabc.Callable[PRMS, R]:
        def _inner(*args: PRMS.args, **kwargs: PRMS.kwargs) -> R:
            if len(inspect.signature(cb).parameters):
                cb(*args, **kwargs)
            else:
                cb()
            return fn(*args, **kwargs)
        return _inner
    return _decor
# All passing

def probe_a(u: int, v: str) -> None: ...
def probe_b() -> None: ...

@fuse_hooks(probe_a)
def target_a(u: int, v: str) -> None: ...

@fuse_hooks(probe_b)
def target_b(u: int, v: str) -> None: ...


target_a(1, "test")
target_b(1, "test")
# Type-checking in action

@fuse_hooks(probe_a)  # Fail: extra parameter, doesn't match `probe_a`'s signature
def target_a_fail(u: int, v: str, w: bytes) -> None: ...

@fuse_hooks(probe_b)  # Pass: signature is preserved
def target_b_any(u: int, v: str, w: bytes) -> None: ...

target_b_any(1, "test")  # Fail: missing argument `w: bytes`

Why this matters

Decorators are a common place where generics, ParamSpec, and overloads intersect. A single factory that supports both a matching-signature callable and a zero-arg callable is ergonomic, but the generics tell two different stories. Overloads let you express those stories separately so the checker preserves the wrapped function’s signature and enforces matching where appropriate. In some codebases it may be simpler to define the zero-arg callable to accept and ignore arbitrary arguments, avoiding the special typing for Callable[[], Any], but the overload approach provides precise checking when you want it.

Takeaways

When a ParamSpec plays different roles across scenarios, unifying them with a union won’t type-check because the generic constraints conflict. Model each scenario with an overload instead. Keep the runtime implementation flexible with Callable[..., Any], and, where necessary, apply targeted ignores for known pyright overlap issues. This keeps your decorators ergonomic while preserving static guarantees where they actually matter.