Writing a Decorator That Preserves the Wrapped Signature
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
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 theCallable[[Callable[P, R]], Callable[P, R]]return type holds theParamSpec. - Methods: On instance methods,
Pcaptures the parameters afterself; the descriptor protocol bindsselfseparately, so you do not add it toConcatenate. - Async functions: Type the return as
Callable[P, Awaitable[R]]and keep the wrapperasync;Pstill 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. ReturnCallable[P, R]. - Annotating only
*args: P.args:P.argsrequires a matching**kwargs: P.kwargson the same function. Omitting it raises mypy[valid-type]/ pyrightreportInvalidTypeVarUse. - Adding
selftoConcatenateon a method: The descriptor already bindsself; 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.