Preserve Decorator Signatures with typing.ParamSpec
In Python, writing decorators that maintain accurate type information
has historically been a challenge for static type checkers. When
wrapping a function, standard type annotations often erase the original
parameter names, types, and default values. Introduced in Python 3.10
via PEP 612, typing.ParamSpec solves this problem by
capturing the parameter signature of a callable and forwarding it to the
wrapper function, allowing tools like mypy, pyright, and modern IDEs to
retain full autocompletion and type safety across decorated
functions.
The Problem with Traditional Decorator Typing
Before ParamSpec, typing higher-order functions relied
on typing.TypeVar and typing.Callable. While
TypeVar can capture the return type, Callable
cannot dynamically capture an arbitrary list of parameters. Developers
typically used Callable[..., R]:
from typing import Callable, TypeVar
R = TypeVar("R")
def simple_decorator(func: Callable[..., R]) -> Callable[..., R]:
def wrapper(*args, **kwargs) -> R:
return func(*args, **kwargs)
return wrapperWhile this preserves the return type R, the ellipsis
(...) discards all argument information. A type checker
will accept any arguments passed to the decorated function, silently
permitting invalid calls and disabling IDE parameter hints.
How ParamSpec Works
ParamSpec (Parameter Specification Variable) acts
similarly to a TypeVar, but instead of representing a
single type, it represents the entire parameter specification of a
callable. This includes:
- Positional-only parameters
- Keyword-only parameters
- Default values
*argsand**kwargstypes
When defining a decorator, ParamSpec is declared
alongside a return TypeVar. The input function is annotated
as Callable[P, R], and the wrapper function or return type
is also annotated using P and R.
from collections.abc import Callable
from typing import ParamSpec, TypeVar
import functools
P = ParamSpec("P")
R = TypeVar("R")
def trace(func: Callable[P, R]) -> Callable[P, R]:
@functools.wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
print(f"Calling {func.__name__}")
result = func(*args, **kwargs)
print(f"Finished {func.__name__}")
return result
return wrapperKey Components
P = ParamSpec("P"): Declares the parameter specification variable.Callable[P, R]: Binds the parameter specificationPto the exact argument types and order of the original function.P.argsandP.kwargs: Used to annotate*argsand**kwargsinside the inner wrapper function. This ensures that the wrapper only accepts arguments valid forPand passes them through properly.
Static Analysis and Developer Experience
When a function is wrapped using this pattern, static analysis tools
substitute P with the concrete parameters of the decorated
function:
@trace
def greet(name: str, repeat: int = 1) -> str:
return f"Hello, {name}!" * repeat
# Valid call: Type checker verifies parameters against `greet`
greet("Alice", repeat=2)
# Invalid call: Type checker raises an error (Argument 1 expects str, got int)
greet(123)Because ParamSpec binds directly to the original
callable's signature, IDEs can display the original parameter names and
default values during autocomplete, and static analyzers can catch
signature mismatches at compile time without altering runtime
behavior.