Typing decorators: ParamSpec and Self

typing · Advanced · 13 min

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 preserved

Remember

  • 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.

Check yourself

  1. Why use ParamSpec in a decorator's type?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: docs.python.org/3/library/typing.html#typing.ParamSpec