Python ParamSpec: Type-Safe Decorators addresses a recurring problem in Python projects: Forward a complete callable signature through a higher-order function. This guide explains the mechanism, provides an executable example, and identifies the boundaries that keep an implementation reliable.
Concept and use case
ParamSpec captures positional and keyword parameters; TypeVar captures the result. Together they preserve the relationship between input and returned callables.
For the related fundamentals, also read the type hints guide. Integration stays simpler when functions receive dependencies and data explicitly instead of relying on global state.
Practical example
from collections.abc import Callable
from functools import wraps
from typing import ParamSpec, TypeVar
P = ParamSpec("P")
R = TypeVar("R")
def traced(func: Callable[P, R]) -> Callable[P, R]:
@wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
print(func.__name__)
return func(*args, **kwargs)
return wrapper
Use functools.wraps for runtime metadata and Callable[P, R] for the static contract. Concatenate handles a wrapper that prepends one argument.
Important decisions
The correct choice depends on the public contract, expected volume, and failure behavior.
Consider concurrency, empty inputs, and partial failures. Document every limit that affects consumers and choose names that express intent.
Common mistakes
A minimal example does not replace bounds, error handling, and observability. Callable[..., R] loses argument checking. ParamSpec does not preserve names, docs, or runtime introspection by itself without wraps.
Avoid catching exceptions without context or returning partial output as if it were complete. An explicit failure is usually safer than silently incorrect data.
How to validate
Validate behavior, not only the happy path. Run a type checker against functions with different signatures and verify autocomplete, rejected arguments, and inferred return type.