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 wrapper

While 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:

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 wrapper

Key Components

  1. P = ParamSpec("P"): Declares the parameter specification variable.
  2. Callable[P, R]: Binds the parameter specification P to the exact argument types and order of the original function.
  3. P.args and P.kwargs: Used to annotate *args and **kwargs inside the inner wrapper function. This ensures that the wrapper only accepts arguments valid for P and 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.