backend / python core / typing / 06_paramspec_concatenate.md

ParamSpec and Concatenate — typing decorators

2 min read source

ParamSpec and Concatenate — typing decorators

ParamSpec (PEP 612, Python 3.10+) lets you type decorators and higher-order functions that preserve the original signature, instead of erasing to Callable[..., T].

The problem

Pre-ParamSpec decorator typing was lossy:

from typing import Callable, TypeVar
import functools

F = TypeVar("F", bound=Callable[..., object])

def log(fn: F) -> F:
    @functools.wraps(fn)
    def wrapper(*args, **kwargs):
        print(f"calling {fn.__name__}")
        return fn(*args, **kwargs)
    return wrapper   # type: ignore   ← wrapper isn't actually F, just a Callable

Type checkers either accept this with a # type: ignore, or treat the wrapped function as the original (which is mostly correct but loses kwarg validation in the wrapper itself).

ParamSpec solution

from typing import Callable, ParamSpec, TypeVar
import functools

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

def log(fn: Callable[P, R]) -> Callable[P, R]:
    @functools.wraps(fn)
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
        print(f"calling {fn.__name__}")
        return fn(*args, **kwargs)
    return wrapper

@log
def add(a: int, b: int) -> int:
    return a + b

add(1, 2)         # int
add(1, "x")       # mypy error — original signature preserved
add(1, b=2)       #
add(1)            # mypy error — missing argument

P.args and P.kwargs are the only ways to use P inside the wrapper.

Concatenate — adding/removing leading parameters

What if your decorator adds a parameter to the wrapped function?

from typing import Callable, Concatenate, ParamSpec, TypeVar

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

def with_user(fn: Callable[Concatenate[User, P], R]) -> Callable[P, R]:
    """Decorator that injects current user as first arg."""
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
        return fn(get_current_user(), *args, **kwargs)
    return wrapper

@with_user
def post_message(user: User, text: str) -> None: ...

post_message("hello")   # — User auto-injected

Concatenate[User, P] means “User, followed by P”. The decorator removes the leading parameter from the public-facing signature.

Use cases

  • Logging / timing decorators that preserve signatures
  • Decorators that inject context (DB session, current user, request)
  • Caching decorators (functools.lru_cache is typed with ParamSpec internally in stubs)
  • FastAPI / Starlette dependency injection

When NOT to use

For simple decorators where you don’t care about preserving the exact signature, Callable[..., R] is fine and simpler:

def deprecated(fn: Callable[..., R]) -> Callable[..., R]:
    def wrapper(*args, **kwargs):
        warnings.warn(f"{fn.__name__} is deprecated")
        return fn(*args, **kwargs)
    return wrapper

Interview angle

“How would you type a @cached decorator that doesn’t lose the wrapped function’s signature?” The answer using ParamSpec shows fluency with modern typing. Senior follow-up: “What if your decorator changes the signature, like adding a parameter?” → Concatenate.