Untyped decorators erase the signatures of everything they wrap, silently disabling checking at every call site.
A decorator typed as Callable[..., Any] -> Callable[..., Any] turns every decorated function into "accepts anything, returns Any". ParamSpec captures a function's whole parameter list so the wrapper can re-use it:
def logged[**P, R](fn: Callable[P, R]) -> Callable[P, R]:
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
return fn(*args, **kwargs)
return wrapper**P in the 3.12 type-parameter syntax declares a ParamSpec (older code: P = ParamSpec("P")). P.args and P.kwargs must be used together on *args and **kwargs. Callers of the decorated function get full checking of argument names and types.
`Concatenate[X, P]` describes adding or removing a leading parameter, for example a decorator that injects a database session as the first argument: it takes Callable[Concatenate[Session, P], R] and returns Callable[P, R].
`Self` (3.11) is the type of the current instance, ideal for methods returning self or alternative constructors. Unlike hardcoding the class name, it stays correct in subclasses:
def with_name(self, name: str) -> Self: ...Still use functools.wraps inside decorators so runtime metadata (__name__, __doc__) is copied too.
Example
import functools
from collections.abc import Callable
from typing import Concatenate, Self
def logged[**P, R](fn: Callable[P, R]) -> Callable[P, R]:
@functools.wraps(fn)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
print(f"calling {fn.__name__}{args}")
return fn(*args, **kwargs)
return wrapper
def with_retries[**P, R](fn: Callable[Concatenate[int, P], R]) -> Callable[P, R]:
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
return fn(3, *args, **kwargs) # injects the first argument
return wrapper
@logged
def add(a: int, b: int) -> int: return a + b
@with_retries
def download(retries: int, url: str) -> str: return f"{url} x{retries}"
class Query:
def where(self, cond: str) -> Self: print("where", cond); return self
print(add(2, 3), download("a.io")) # calling add(2, 3) / 5 a.io x3
Query().where("x > 1").where("y < 2") # where x > 1 / where y < 2
# add("2", 3) -> checker error, because the signature was preservedRemember
- def deco[**P, R](fn: Callable[P, R]) -> Callable[P, R] preserves signatures
- Concatenate adds or removes leading parameters
- Self types methods that return the instance, correctly for subclasses
Watch out for
- Typing decorators with Callable[..., Any], which removes checking from every decorated function.