When does a decorator's type need typing.Concatenate rather than a bare ParamSpec?
answer
- A bare ParamSpec copies, never edits
- Fixed leading types plus the rest
- Injecting or demanding a first argument
- Prepend only, positional only
- Keyword additions need a __call__ protocol
basics
~20 sUse Concatenate when the decorator changes the parameter list instead of copying it — supplying a leading positional argument the caller no longer passes, or demanding an extra leading one. A bare ParamSpec can only reproduce the signature unchanged.
solid answer
~40 s`typing.ParamSpec` copies a parameter list verbatim, so `Callable[P, R] -> Callable[P, R]` is right only for decorators that leave the signature alone. `typing.Concatenate` (PEP 612, Python 3.10) lets you write a parameter list that is *some fixed leading positional types plus* a ParamSpec. To **consume** an argument — a decorator that injects a renderer, connection or context and hides it from callers — you type it `Callable[Concatenate[Renderer, P], R] -> Callable[P, R]`. To **add** one, flip it: `Callable[P, R] -> Callable[Concatenate[bool, P], R]`. The constraint is strict: `Concatenate` only prepends, and only positional parameters. A decorator that adds a keyword-only option cannot be expressed this way and needs a `typing.Protocol` with `__call__` instead.
code
python · 19 linesfrom collections.abc import Callable
from typing import Concatenate, ParamSpec, TypeVar
P = ParamSpec("P")
R = TypeVar("R")
class Renderer:
dpi = 300
def with_renderer(fn: Callable[Concatenate[Renderer, P], R]) -> Callable[P, R]:
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
return fn(Renderer(), *args, **kwargs)
return wrapper
@with_renderer
def render(renderer: Renderer, invoice_id: int) -> str:
return f"invoice {invoice_id} at {renderer.dpi} dpi"
print(render(83))go deeper
Recall only the boundary: a decorator that leaves the signature alone is typed with a ParamSpec, and a different tool exists for decorators that add or supply a parameter. The details come later.
Explain the two directions — consuming a leading argument versus demanding one — and write the corresponding annotation. Know that the fixed types are prepended and the parameter-list variable comes last.
Show where it earns its keep: injection decorators are exactly where untyped higher-order code goes wrong. Be ready to state the limits and name the Protocol-with-call fallback for keyword-only additions.
Weigh the cost: how much annotation machinery a shared decorator layer deserves, whether injection should be a decorator at all versus explicit passing, and what the team does on runtimes without the feature.
## Where a bare ParamSpec runs out `Callable[P, R] -> Callable[P, R]` says the decorator hands back a function with *exactly* the parameters it was given. That is true of timing, logging, retry and caching decorators, and it covers most of what people write. It is false the moment the decorator changes the caller's obligations. Two shapes come up constantly in real code: * **The decorator supplies an argument.** An invoice-PDF renderer is written as `def render(renderer: Renderer, invoice_id: int) -> str`, and a decorator builds or looks up the `Renderer` and passes it in. Callers of the decorated function pass only `invoice_id`; the leading parameter has been *consumed*. * **The decorator demands an argument.** A wrapper adds a leading flag or context object that callers must now supply, and forwards the rest. Neither can be written with `P` alone, because `P` is all-or-nothing: it captures the whole list and re-emits the whole list. ## What Concatenate does `typing.Concatenate`, added alongside `ParamSpec` in Python 3.10 by PEP 612, builds a parameter list out of **fixed leading positional types followed by a ParamSpec**. It is only ever legal in the parameter slot of a `Callable`, and the ParamSpec must be its final element: ```python Callable[Concatenate[Renderer, P], R] # takes a Renderer, then whatever P is Callable[Concatenate[str, int, P], R] # two fixed leading parameters, then P ``` Read it as list concatenation at the type level: `[Renderer] + P`. ### Consuming a leading argument ```python def with_renderer(fn: Callable[Concatenate[Renderer, P], R]) -> Callable[P, R]: def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: return fn(Renderer(), *args, **kwargs) return wrapper ``` The input accepts only functions whose first positional parameter is a `Renderer`; the output drops it. A checker now rejects decorating a function that does not start with a `Renderer`, and rejects a caller who still tries to pass one. Both halves of the contract are enforced, which is exactly what the ellipsis form gave up. ### Adding a leading argument ```python def with_dry_run(fn: Callable[P, R]) -> Callable[Concatenate[bool, P], R]: def wrapper(dry_run: bool, *args: P.args, **kwargs: P.kwargs) -> R: return fn(*args, **kwargs) if not dry_run else fn(*args, **kwargs) return wrapper ``` Here the wrapper's own leading parameter is spelled out before the forwarded `*args`, and callers of the decorated function must now pass the flag first. ## The hard limits Three constraints decide whether `Concatenate` is applicable at all: 1. **Leading only.** You cannot append to the end of a parameter list, because a trailing addition would be ambiguous against `P`'s own trailing parameters and defaults. 2. **Positional only.** There is no way to express "adds a keyword-only `verbose` option". When a decorator does that, the honest tool is a `typing.Protocol` with a `__call__` method spelling out the full resulting signature, or an overload set on the decorator. 3. **The ParamSpec goes last.** `Concatenate[P, Renderer]` is not valid; the variable-length part must terminate the list. A practical consequence: when a caller of the *undecorated* function would pass the leading argument by keyword — `render(renderer=r, invoice_id=83)` — the `Concatenate` form cannot describe that use, because the consumed parameter is positional in the type. Codebases that lean on this pattern usually make the injected parameter positional-only with a `/` marker, which also stops callers from accidentally shadowing it. ## Why it is worth the effort Argument-injecting decorators are precisely where untyped code goes quietly wrong: the injected object arrives from somewhere the call site cannot see, and a signature mismatch shows up as an argument landing in the wrong parameter rather than as a missing name. In one such failure the shared default object a wrapper passed on every call was mutated by one request and read by the next — the sort of bug that survives testing because the wrapper looked harmless and the type said `Any`. A `Concatenate` annotation makes the decorator's real contract — *"I supply this, you supply the rest"* — checkable at every decoration site and every call, which is the whole return on typing higher-order code. From Python 3.12 the same decorators can be written with inline type parameters, `def with_renderer[**P, R](...)`, which shortens the boilerplate without changing any of the semantics above.
- Can Concatenate append a parameter to the end of a signature?No. It only prepends: the fixed types come first and the ParamSpec must be the last element. Appending would be ambiguous against the captured list's own trailing and defaulted parameters, so the design forbids it. A decorator that genuinely adds a trailing or keyword-only parameter needs a `typing.Protocol` with `__call__`, or overloads.
- How do you type a decorator that adds a keyword-only option to the wrapped function?Not with `Concatenate`, which is positional-only. Declare a `typing.Protocol` whose `__call__` spells out the resulting signature — the forwarded parameters plus the new keyword-only one with its default — and have the decorator return that protocol type. Overloads on the decorator are the other route when only a few concrete shapes exist.
- What breaks if a caller passes the consumed leading argument by keyword?The `Concatenate` form describes that parameter positionally, so the keyword call cannot be expressed by the type. Codebases relying on injection usually mark the injected parameter positional-only with a `/` in the wrapped function's signature, which both matches the annotation and prevents a forwarded keyword argument from colliding with it.
saying these in an interview costs you the question
- Thinks ParamSpec alone can add or drop a parameter
- Tries to append with Concatenate instead of prepending
- Puts the ParamSpec first inside Concatenate
- Expects Concatenate to express keyword-only additions
- Falls back to Callable[..., Any] for injecting decorators
- Confuses consuming a parameter with changing the return type