Writing a Decorator That Preserves the Wrapped Signature

TL;DR

Type the decorator with a ParamSpec P and a return TypeVar R: take Callable[P, R], annotate the wrapper’s *args: P.args, **kwargs: P.kwargs, and return Callable[P, R]. A plain Callable[..., R] throws the parameter list away, so callers lose argument checks. Add functools.wraps for correct runtime metadata, and reach for Concatenate to inject or strip a leading argument.

Decorators like @timed or @retry wrap a function and return a new callable. If you type that callable as Callable[..., R], every caller of the decorated function stops being checked — the ... means “any arguments”. PEP 612 (Python 3.10) fixed this with ParamSpec and Concatenate, which let the wrapper re-expose the wrapped function’s exact Callable signature. This guide builds a signature-preserving @timed and a @retry, then shows the Concatenate variant, noting what mypy and pyright see at each step.

Step 1 — See why Callable[..., R] is wrong

Start with the naive form. It type-checks, but the decorated function accepts anything.

# Python 3.10+, mypy 1.x — the lossy version
from collections.abc import Callable
from typing import TypeVar

R = TypeVar("R")

def timed(func: Callable[..., R]) -> Callable[..., R]:   # `...` erases parameters
    def wrapper(*args, **kwargs) -> R:
        return func(*args, **kwargs)
    return wrapper

@timed
def fetch(url: str, retries: int) -> bytes: ...
fetch(123)                           # NO error — `...` accepts any args (the bug)

mypy and pyright stay silent on fetch(123) even though url should be a str. The signature was discarded.

Step 2 — Capture the signature with ParamSpec

Replace ... with a ParamSpec. Now the wrapper’s parameters are tied to the wrapped function’s.

# Python 3.10+, mypy 1.x / pyright
from typing import ParamSpec, TypeVar

P = ParamSpec("P")
R = TypeVar("R")

def timed(func: Callable[P, R]) -> Callable[P, R]:
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
        return func(*args, **kwargs)
    return wrapper

@timed
def fetch(url: str, retries: int) -> bytes: ...
fetch(123, 2)                        # mypy error: [arg-type]
# pyright: reportArgumentType — argument "url" expects str, got int

The analyzer now checks calls through the decorator: 123 for url: str is rejected exactly as if the decorator were not there.

Step 3 — Add functools.wraps for runtime metadata

functools.wraps copies __name__, __doc__, and __wrapped__ onto the wrapper. It changes nothing about the static type, but frameworks and help() rely on it.

# Python 3.10+, mypy 1.x
import functools

def timed(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

Step 4 — Apply the same shape to @retry

A @retry decorator that re-invokes on failure uses the identical Callable[P, R] -> Callable[P, R] shape; only the wrapper body differs.

# Python 3.10+, mypy 1.x / pyright
def retry(attempts: int) -> Callable[[Callable[P, R]], Callable[P, R]]:
    def decorate(func: Callable[P, R]) -> Callable[P, R]:
        @functools.wraps(func)
        def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
            last: Exception | None = None
            for _ in range(attempts):
                try:
                    return func(*args, **kwargs)
                except Exception as exc:
                    last = exc
            raise last  # type: ignore[misc]  # last is non-None after the loop
        return wrapper
    return decorate

Because retry takes an argument, the outer function returns a decorator, so the ParamSpec lives one level deeper — but the preservation pattern is unchanged.

Step 5 — Inject a leading argument with Concatenate

When the wrapper supplies an argument the caller should not pass, use Concatenate to strip it from the public signature.

# Python 3.10+, mypy 1.x / pyright
from typing import Concatenate

def with_session(
    func: Callable[Concatenate[Session, P], R],
) -> Callable[P, R]:
    @functools.wraps(func)
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
        return func(open_session(), *args, **kwargs)
    return wrapper

@with_session
def run_query(session: Session, sql: str) -> list[Row]: ...
run_query("SELECT 1")                # session injected; only sql remains
Runtime vs static analysis None of this changes runtime behaviour. ParamSpec, Concatenate, and the P.args/P.kwargs annotations are erased at runtime — the wrapper still receives whatever Python passes it, and no argument checking happens during execution. They exist purely so mypy and pyright can re-impose the wrapped signature statically. functools.wraps is the only line here with a real runtime effect (copying metadata); the type annotations are advisory.

Edge cases

  • Decorators with arguments: As in @retry(3), the outer callable returns the decorator, so the Callable[[Callable[P, R]], Callable[P, R]] return type holds the ParamSpec.
  • Methods: On instance methods, P captures the parameters after self; the descriptor protocol binds self separately, so you do not add it to Concatenate.
  • Async functions: Type the return as Callable[P, Awaitable[R]] and keep the wrapper async; P still carries the parameters.

Common mistakes

  • Returning Callable[..., R]: The single most common bug — it silences all argument checks. Callers passing wrong types get no [arg-type] / reportArgumentType. Return Callable[P, R].
  • Annotating only *args: P.args: P.args requires a matching **kwargs: P.kwargs on the same function. Omitting it raises mypy [valid-type] / pyright reportInvalidTypeVarUse.
  • Adding self to Concatenate on a method: The descriptor already binds self; including it double-counts the first parameter and produces [arg-type] at every call.

FAQ

Does functools.wraps help the type checker? No — it only copies runtime metadata. The static signature comes entirely from ParamSpec. Keep both: wraps for runtime correctness, ParamSpec for static correctness.

Why does my decorated method lose self? You probably typed the decorator with Callable[..., R] or added self into Concatenate. Use Callable[P, R] and let P capture the post-self parameters.

Back to ParamSpec and Concatenate