How does typing.ParamSpec let a decorator preserve the wrapped function's signature?
answer
- The ellipsis form erases what callers knew
- A variable for a whole parameter list
- Callable[P, R] in, Callable[P, R] out
- Two members, used only together
- Runtime metadata is a separate concern
basics
~20 sParamSpec is a type variable that captures a whole parameter list. Typing a decorator as Callable[P, R] to Callable[P, R], with the inner wrapper declared *args: P.args, **kwargs: P.kwargs, hands callers back the original signature instead of erasing it.
solid answer
~40 sA decorator typed `Callable[..., Any] -> Callable[..., Any]` type-checks, but every decorated function loses its signature: callers can pass anything, and the return type collapses to `Any`. `typing.ParamSpec` (PEP 612, Python 3.10) fixes that by making the *parameter list itself* a variable. You declare `P = ParamSpec("P")` and a return `TypeVar`, then type the decorator `Callable[P, R] -> Callable[P, R]`. Inside, the wrapper must be spelled `def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:` — those two members are the only legal use of `P` there, and they must appear together. A checker then instantiates `P` with each decorated function's real parameters, so wrong arguments are still caught at the call site. `functools.wraps` remains necessary, but it is orthogonal: it copies runtime metadata like `__name__` and `__doc__`, not static typing information.
code
python · 25 linesimport functools
from collections.abc import Callable
from typing import ParamSpec, TypeVar
P = ParamSpec("P")
R = TypeVar("R")
def memoized(fn: Callable[P, R]) -> Callable[P, R]:
cache: dict[tuple[object, ...], R] = {}
@functools.wraps(fn)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
key = (args, tuple(sorted(kwargs.items())))
if key not in cache:
cache[key] = fn(*args, **kwargs)
return cache[key]
return wrapper
@memoized
def render_invoice(invoice_id: int, *, watermark: str = "") -> bytes:
return f"invoice-{invoice_id}{watermark}".encode()
print(render_invoice(83, watermark="-PAID"))
print(render_invoice(83, watermark="-PAID"))go deeper
Know that decorating a function can hide its signature from tooling, and that the modern fix has a name. Recognising Callable[P, R] -> Callable[P, R] as the shape of a well-typed decorator is enough at this level.
Be ready to write the decorator from scratch: declare the ParamSpec and return TypeVar, type both sides Callable[P, R], and spell the wrapper *args: P.args, **kwargs: P.kwargs. Explain why functools.wraps does not cover this.
Demonstrate the diagnosis: a decorated call passed the wrong argument type and nothing caught it, because the decorator erased the signature. Show the fix and the return-type-changing variants, and know the version floor for the feature.
Own the standard: whether shared decorators in the codebase are required to be signature-preserving, how much a backport import shim is worth on older runtimes, and where a Protocol or overload set is the honest answer instead.
## The problem: decorators erase signatures A decorator replaces a function with a wrapper. If you annotate that transformation loosely, you also throw away what callers knew: ```python def memoized(fn: Callable[..., Any]) -> Callable[..., Any]: ... ``` Everything still checks, and that is the danger. After decoration, an invoice renderer declared `render_invoice(invoice_id: int, *, watermark: str = "") -> bytes` looks to a checker like a function of unknown parameters returning `Any`. Call it with the id as a string, misspell the keyword, or add a stray positional argument, and nothing complains. The bug surfaces at runtime — often far from the decorator, in the reporting path that only exercises the cache-miss branch when the hit rate drops below its usual 83%. The earlier workaround was a `TypeVar` bound to `Callable`: ```python F = TypeVar("F", bound=Callable[..., Any]) def memoized(fn: F) -> F: ... ``` This preserves the signature exactly and is still seen in older code, but it is rigid: it can only return *the identical* callable type, so a decorator that changes the return type, adds a parameter, or wraps a sync function into an async one cannot be expressed. ## What ParamSpec is `typing.ParamSpec`, added in Python 3.10 by PEP 612, is a type variable whose value is **an entire parameter list** — positional, keyword, defaults, ordering and all — rather than a single type. Where `TypeVar` lets you say "whatever type comes in comes out", `ParamSpec` lets you say "whatever *signature* comes in comes out". ```python P = ParamSpec("P") R = TypeVar("R") def memoized(fn: Callable[P, R]) -> Callable[P, R]: ... ``` `Callable[P, R]` places the ParamSpec in the parameter slot where a list of types would normally go. A checker binds `P` to the concrete parameter list of each decorated function and `R` to its return type, then re-applies both to the result. Callers of the decorated renderer keep full checking: the id must be an `int`, `watermark` must be a `str` keyword, and the result is `bytes`, not `Any`. ## P.args and P.kwargs Inside the wrapper, the two members of a ParamSpec appear as annotations: ```python def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: return fn(*args, **kwargs) ``` Three rules govern them. They may only annotate the `*args` and `**kwargs` parameters of a function; they must be used **together** in the same signature, never one alone; and their whole meaning is "these two together represent exactly one call to `P`". That last point is what allows the checker to verify that the wrapper forwards the arguments faithfully — `fn(*args, **kwargs)` is accepted, while dropping, reordering or injecting arguments is not. ## What ParamSpec is not It is not `functools.wraps`, and the two are not alternatives. `functools.wraps` is a runtime device that copies `__name__`, `__doc__`, `__module__`, `__qualname__` and the wrapped function's `__dict__` onto the wrapper so introspection and documentation tools see the original. It does nothing for a static checker, and the checker does nothing for runtime introspection. A well-typed decorator uses both. It is also not limited to identity transformations. Because the return type is a separate variable, a decorator may legitimately change it — `Callable[P, R] -> Callable[P, Awaitable[R]]` types a wrapper that makes a sync function awaitable, keeping the parameters intact. And a ParamSpec can be declared with a default in the PEP 696 sense, or spelled inline as `def memoized[**P, R](fn: Callable[P, R]) -> Callable[P, R]` using the type-parameter syntax available from 3.12. ## Why the loss spreads The reason a weak decorator annotation matters more than it first appears is that `Any` is contagious through a call graph. The decorated renderer returns `Any`; the function that calls it assigns that to a local, passes it on, and every downstream annotation that depended on the result being `bytes` is now being checked against a value the checker has agreed not to reason about. One loosely typed decorator applied across a module's public functions can quietly switch off checking for a whole layer, and because nothing errors, the loss never appears in a build log — it appears months later as a class of bug the team assumed the checker was catching. That is also why `ParamSpec` is worth applying to decorators that seem trivial. A logging or timing decorator adds no behaviour worth typing, but it sits on top of functions whose signatures are the ones callers actually rely on. ## Limits worth knowing A bare `ParamSpec` copies the parameter list unchanged. It cannot express a decorator that *adds* or *consumes* a parameter — that is `Concatenate`'s job, and only for leading positional parameters. It also cannot express "adds a keyword-only option", which still needs a `Protocol` with `__call__` or an overload set. And on Python versions before 3.10 the names live in a backport package rather than the standard library, which is why long-lived codebases sometimes carry an import shim. The practical rule: any decorator you write in a typed codebase should be `Callable[P, R]`-shaped by default. The ellipsis form should have to argue for itself.
- Why must P.args and P.kwargs appear together rather than singly?Because the pair is what represents one complete call to the captured parameter list. `P.args` alone would claim the wrapper accepts only the positional half of `P`, which no real forwarding wrapper does. Type checkers reject a signature that annotates one without the other, and reject a wrapper that forwards them anywhere except as `fn(*args, **kwargs)`.
- How would you type a decorator that turns a sync function into an async one?Keep the parameters and change the result: `Callable[P, R] -> Callable[P, Awaitable[R]]`. `ParamSpec` fixes only the parameter list, so the return `TypeVar` is free to be wrapped. Callers keep exact argument checking on the decorated function and correctly see that its result must be awaited.
- If functools.wraps copies metadata, why does a checker still not see the signature?`functools.wraps` runs at runtime, copying attributes such as `__name__`, `__doc__` and `__module__` onto the wrapper object. A static checker analyses source before anything runs and has no general way to model that copying, so it reads the wrapper's own declared annotations. `ParamSpec` supplies the static story; `wraps` supplies the runtime one.
- What did people do before ParamSpec existed?The common trick was `F = TypeVar("F", bound=Callable[..., Any])` with the decorator typed `def deco(fn: F) -> F`. That preserves the signature exactly, but only for decorators that return the same callable type — it cannot express a changed return type, an added parameter, or a sync-to-async wrapper. Many codebases also simply used `Any` and lost the checking.
A TypeVar is a placeholder for one ingredient; a ParamSpec is a placeholder for the whole shopping list, so the wrapper can hand the exact same list through to the function it wraps.
saying these in an interview costs you the question
- Says functools.wraps restores the signature for a type checker
- Types every decorator Callable[..., Any] and calls it fine
- Annotates only *args with P.args, omitting P.kwargs
- Thinks ParamSpec is just a TypeVar with another name
- Believes ParamSpec can add a parameter to the signature
- Claims decorated functions cannot be typed precisely at all