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_cacheis typed withParamSpecinternally 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.