Typing decorators: why the checker goes quiet, and what fixes it
A decorator declared as Callable[..., R] lets both deliberately wrong calls through — and that is not the type checker failing, it is exactly what the ellipsis means. Meanwhile the signature is still there at runtime: inspect shows the original one. The runtime is honest and the checker is blind — and people usually expect the opposite.
Full technical treatment
TL;DR
- A decorator declared as
Callable[..., R]lets both deliberately wrong calls through, out of the three written. The type checkers —mypyandpyright— are not broken: the ellipsis means not "any arguments" but "do not check". - The same code with a
ParamSpecgives three errors on the same two lines. One line changes — the decorator's declaration. - The signature is intact at runtime all along:
inspect.signatureshows the original one, becausefunctools.wrapsleft a reference to the wrapped function behind. - A decorator factory is where nearly everyone gets it wrong:
@tracekeeps the signature,@trace(level=2)loses it silently.
Two wrong calls, zero errors
Here is a decorator written the way they are most often written:
def add_logging(f: Callable[..., R]) -> Callable[..., R]:
def inner(*args: object, **kwargs: object) -> R:
return f(*args, **kwargs)
return inner
@add_logging
def takes_int_str(x: int, y: str) -> int:
return x + 7Three calls under it, two of them deliberately wrong: takes_int_str(1, "A"),
takes_int_str("B", 2) with the types swapped, and takes_int_str() with no
arguments at all.
The type checker — mypy — finds none of them. Zero errors, exit code zero.
It is the ellipsis
"... means any arguments" is an explanation that sounds harmless. PEP 612 puts
it differently: an ellipsis in place of parameter types means that no
validation of the arguments is done at all.
The gap between "any is acceptable" and "we do no validation" is exactly the gap between a harmless-sounding phrase and three calls let through. The checker did exactly what it was told. It was told badly.
ParamSpec: the same function, three errors
def add_logging[**P, R](f: Callable[P, R]) -> Callable[P, R]:
def inner(*args: P.args, **kwargs: P.kwargs) -> R:
return f(*args, **kwargs)
return innerThe decorator's body has not changed, the calls have not changed. The declaration has — and the checker now finds all three errors: two about types and one about the number of arguments.
P.args and P.kwargs are only ever written as a pair. The reason is not
notational: different calls split the same set of parameters differently —
f(1, y=2) puts the two into kwargs, f(1, 2) puts it into args. Half of
the pair would fail to describe every case, and both checkers reject it.
The runtime is honest, the checker is blind
The most unexpected part is that the signature never went anywhere at runtime:
inspect.signature(takes_int_str) -> (x: int, y: str) -> int
inspect.signature(takes_int_str, follow_wrapped=False) -> (*args, **kwargs) -> int
The second line is the truth: what will be called is a function taking
*args, **kwargs. The first is what inspect shows by default, because
functools.wraps left a reference to the wrapped function on the wrapper and
signature hops along it.
Hence the belief that "wraps fixes everything". It fixes help() and
introspection. It tells the checker nothing.
Where nearly everyone gets it wrong
A decorator supporting both forms — @trace and @trace(level=2) — is written
as a pair of overloads, and it looks impeccable. Measured:
| how it was decorated | what the checker sees |
|---|---|
@trace | def (x: int) -> str |
@trace(level=2) | def (*Any, **Any) -> object |
The second row is a loss, and a silent one: a wrong call to such a function is simply not flagged.
The cause is that a factory has to return one type, while a ParamSpec has
to bind afresh at every application of the decorator. The cure is a factory that
returns not a Callable but a protocol with a generic __call__:
class _Deco(Protocol):
def __call__[**P, R](self, f: Callable[P, R], /) -> Callable[P, R]: ...After that both forms give the same signature.
What measured this
Every example lives as a whole file in bench/typing/: from
bench/typing/01_naive.py through bench/typing/07_args_kwargs_pair.py, plus
bench/typing/version_matrix.py for the version table. There are two checkers —
mypy 2.3.1 and pyright 1.1.413 under CPython 3.13.7 — and they agreed on
every file.
What either of them prints is an observation, not a source: the typing specification comes first, and wherever the word "rule" appears above, it is the specification or a PEP standing behind it.
TL;DR
- A decorator declared as
Callable[..., R]lets both deliberately wrong calls through, out of the three written. There are two type checkers here —mypy 2.3.1andpyright 1.1.413— and both stay silent; that is not either of them failing: PEP 612 says it outright — with an ellipsis, "we do no validation on arguments". ParamSpec(PEP 612) on the same code produces three errors on the same two lines: two about types and one about the count. Only the decorator's declaration changes; neither its body nor the calls are touched.P.argsandP.kwargsexist only as a pair, and both checkers refuse half of one. The reason is in the PEP: two valid calls may split the same set of parameters differently.- The runtime is honest while the checker is blind — and people usually
expect the opposite.
inspect.signatureshows the original signature, becausefunctools.wrapsleft__wrapped__behind andinspecthops along it. Concatenatecan only prepend positional parameters. A keyword-only one cannot be added this way, and that is a limitation of the PEP itself, not of the checkers.- A decorator factory is where nearly everyone gets it wrong:
@tracekeeps the signature,@trace(level=2)loses it silently. The cure is a protocol with a generic__call__.
Zero errors on two wrong calls
Start with the measurement, not the explanation. Here is an ordinary decorator, written the way they are most often written:
def add_logging(f: Callable[..., R]) -> Callable[..., R]:
def inner(*args: object, **kwargs: object) -> R:
return f(*args, **kwargs)
return inner
@add_logging
def takes_int_str(x: int, y: str) -> int:
return x + 7And three calls under it, two of them deliberately wrong:
takes_int_str(1, "A") # a correct call
takes_int_str("B", 2) # the types are swapped
takes_int_str() # no arguments at allThe type checker — mypy 2.3.1 — finds none of them. The file in full is
bench/typing/01_naive.py; the output verbatim:
$ mypy 01_naive.py
-> exit=0
No error, no warning, exit code zero. The second checker, pyright 1.1.413,
is silent on the same file too.
What the ellipsis means
The usual explanation — "... means any arguments" — sounds harmless, which is
why it does not put anyone on guard. The typing documentation phrases it that
way:
If a literal ellipsis ... is given as the argument list, it indicates that a
callable with any arbitrary parameter list would be acceptable.
About what happens to the checking of CALLS to such an object, it says nothing. PEP 612 does say it, and in rather different words:
This was not caught by the type checker because the decorated takes_int_str
was given the type Callable[..., Awaitable[int]] (an ellipsis in place of
parameter types is specified to mean that we do no validation on arguments).
The gap between "any is acceptable" and "we do no validation" is exactly the gap
between a harmless-sounding phrase and three calls let through. The typing
specification puts it more strictly: This is a gradual form indicating that the type is consistent with any input signature
.
A gradual form is a place where checking is deliberately switched off.
Notice what this explanation does not contain: the word "bug". The checker did exactly what it was told. It was told badly.
ParamSpec: the same code, three errors
PEP 612 exists precisely to fix this. Its problem statement is about decorators in as many words:
Neither of these support forwarding the parameter types of one callable over to
another callable, making it difficult to annotate function decorators.
The same decorator, rewritten with a ParamSpec in PEP 695 syntax:
def add_logging[**P, R](f: Callable[P, R]) -> Callable[P, R]:
def inner(*args: P.args, **kwargs: P.kwargs) -> R:
return f(*args, **kwargs)
return innerThe decorator's body has not changed. The calls have not changed. The file of
calls is the same file. One line changed — the declaration. The output
(bench/typing/02_paramspec.py):
02_paramspec.py:22: error: Argument 1 to "takes_int_str" has incompatible type "str"; expected "int" [arg-type]
02_paramspec.py:22: error: Argument 2 to "takes_int_str" has incompatible type "int"; expected "str" [arg-type]
02_paramspec.py:23: error: Missing positional arguments "x", "y" in call to "takes_int_str" [call-arg]
Three errors on the same two lines — two about types and one about the count.
pyright finds the same three, in its own words. This article, in essence, comes down to that pair of runs: ... is not
"any arguments", it is "do not check".
Why P.args and P.kwargs come only as a pair
The first thing people trip over is writing half of it:
def half[**P, R](f: Callable[P, R]) -> Callable[P, R]:
def inner(*args: P.args) -> R: # error: half of a pair
return f(*args)
return innerBoth checkers refuse, each in its own words
(bench/typing/07_args_kwargs_pair.py):
07_args_kwargs_pair.py:26: error: ParamSpec must have "*args" typed as "P.args" and "**kwargs" typed as "P.kwargs" [valid-type]
And two more follow, on lines 27 and 29: the call is left without parameters, and the result no longer fits the declared type. The second checker refuses on the same three lines and names the rule more directly:
07_args_kwargs_pair.py:26: "args" and "kwargs" attributes of ParamSpec must both appear within a function signature
The reason is named in the PEP, and it is not about notational convenience:
A ParamSpec captures both positional and keyword accessible parameters, but
there unfortunately is no object in the runtime that captures both of these
together.
Then comes the point of the restriction. Different calls split the same set of
parameters differently: f(1, y=2) puts the one into args and the two into
kwargs, while f(1, 2) puts both into args.
Therefore, we need to make sure that these special types are only brought into
the world together, and are used together, so that our usage is valid for all
possible partitions.
So the pair is not a syntactic formality but the only way to say "the very same parameters" without knowing in advance how the caller will split them.
The runtime is honest, the checker is blind
Here begins the part that is written down almost nowhere, and it inverts the picture people carry.
While the checker was silent, the signature never went anywhere at runtime. Measured on 3.12.3, 3.13.7 and 3.14.7 — identical on all three:
inspect.signature(takes_int_str) -> (x: int, y: str) -> int
inspect.signature(takes_int_str, follow_wrapped=False) -> (*args, **kwargs) -> int
takes_int_str.__code__.co_varnames -> ('args', 'kwargs')
Read it from the bottom up. The last line is the truth: under the decorator sits
a function taking *args, **kwargs, and that is the one that will be called.
The middle line is the same truth, spoken by inspect when asked not to hop.
The top line is what inspect shows by default.
The hop is made possible by functools.wraps — the mechanism itself is taken
apart in the article on decorators, and here it is taken as given. wraps puts
a reference to the wrapped function on the wrapper (wrapper.__wrapped__ = wrapped, Lib/functools.py:62
on 3.13.7), and inspect.signature is declared to follow it:
def signature(obj, *, follow_wrapped=True, ...) (Lib/inspect.py:3373).
That is where the belief that "functools.wraps fixes everything" comes from.
It fixes introspection, help() and everything that reads __wrapped__. It
tells the checker nothing: wraps is code that runs at decoration time, and
the checker never gets as far as running anything.
The result is a picture rare in Python: the runtime tool knows the truth, and the tool whose whole job is to warn you in advance does not. Usually it is the other way round, and the mistake lives on that expectation.
When the decorator adds an argument
ParamSpec describes "the very same parameters". But a decorator often changes
the signature: it supplies something itself and does not expose it outward. For
those cases PEP 612 introduces Concatenate:
The semantics of Concatenate[X, Y, P] are that it represents the parameters
represented by P with two positional-only parameters prepended.
A decorator that supplies a database connection and hides it from the outside
(bench/typing/03_concatenate_and_protocol.py):
def needs_conn[**P, R](f: Callable[Concatenate[str, P], R]) -> Callable[P, R]:
def inner(*args: P.args, **kwargs: P.kwargs) -> R:
return f("conn", *args, **kwargs)
return inner
@needs_conn
def query(conn: str, sql: str, limit: int) -> int:
return limitFrom the outside query keeps two parameters rather than three, and that is
checked:
| the call | the verdict |
|---|---|
query("select", 10) | accepted: conn was supplied by the decorator |
query(10, "select") | two arg-type errors — the types are in the wrong order |
query("select", 10, 1) | call-arg: Too many arguments for "query" |
The decorator's input type and output type are now different, and that is the
whole point: Callable[Concatenate[str, P], R] going in, Callable[P, R]
coming out.
What Concatenate cannot do
There is no symmetric way to add a keyword parameter. This is not a gap in the checkers, nor something "mypy has not got round to" — the limitation is written into the PEP as its own subsection, together with the reason:
However, the key distinction is that while prepending positional-only
parameters to a valid callable type always yields another valid callable type,
the same cannot be said for adding keyword-only parameters.
The PEP closes the subsection promising to return to the question if there is sufficient demand
.
Apparently the demand has not built up: nothing changed in 3.14.
The practical conclusion is simple. A decorator that adds a keyword parameter
can only be typed with a protocol whose __call__ spells the parameters out by
hand, without a ParamSpec.
Decorating a method: Self survives
The common line "chained calls break after a decorator" can be checked the same
way. Concatenate[S, P] preserves both the receiver and Self
(bench/typing/06_methods.py):
def logged[S, **P, R](m: Callable[Concatenate[S, P], R]) -> Callable[Concatenate[S, P], R]:
def inner(self: S, *args: P.args, **kwargs: P.kwargs) -> R:
return m(self, *args, **kwargs)
return inner
class A:
@logged
def m(self, x: int) -> str:
return str(x)
@logged
def chain(self) -> Self:
return selfThe output:
06_methods.py:24: note: Revealed type is "def (x: int) -> str"
06_methods.py:25: note: Revealed type is "06_methods.A"
06_methods.py:26: error: Argument 1 to "m" of "A" has incompatible type "str"; expected "int" [arg-type]
First line: on a.m the receiver is already bound, and one parameter is visible
from outside. Second: a.chain() gives A, so Self made it through the
decorator. Third: a wrong call to a method is caught just like a wrong call to a
function.
When the decorator attaches an attribute
Callable[P, R] describes something callable, and nothing else. If a decorator
adds an attribute to the object (cache_clear, a counter, anything), that type
has no room for it. The answer is a protocol that has both a __call__ and
everything else (the second half of
bench/typing/03_concatenate_and_protocol.py):
class HasCache[**P, R](Protocol):
__wrapped__: Callable[P, R]
def cache_clear(self) -> None: ...
def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R: ...
def memo[**P, R](f: Callable[P, R]) -> HasCache[P, R]: ...Then h.cache_clear() passes, and h("x") on an h(a: int) is an error — one
that arrives with an address: Argument 1 to "__call__" of "HasCache". Note
that P.args and P.kwargs behave inside a protocol exactly as they do in an
ordinary function: the pair stays a pair.
The factory: where nearly everyone gets it wrong
A decorator that supports both forms — @trace and @trace(level=2) — is
written as a pair of overloads, and it looks impeccable:
@overload
def trace[**P, R](f: Callable[P, R], /) -> Callable[P, R]: ...
@overload
def trace(*, level: int = ...) -> Callable[[Callable[..., object]], Callable[..., object]]: ...The output on two functions decorated by the different forms
(bench/typing/04_factory_broken.py):
04_factory_broken.py:30: note: Revealed type is "def (x: int) -> str"
04_factory_broken.py:31: note: Revealed type is "def (*Any, **Any) -> object"
04_factory_broken.py:32: error: Argument 1 to "a" has incompatible type "str"; expected "int" [arg-type]
The first line is @trace, signature intact. The second is @trace(level=2),
signature gone. The third is the error on a("bad"). And one line below sits
b("bad"), a wrong call in exactly the same way, and it is not flagged at
all.
The cause is visible in the second overload. A factory has to return one
type, while a ParamSpec has to bind afresh at every application of the
decorator. A single Callable[[Callable[..., object]], Callable[..., object]]
cannot do that — and the ellipsis inside it does precisely what this article
opened with.
The cure is a factory that returns not a Callable but a protocol with a
generic __call__ (bench/typing/05_factory_fixed.py):
class _Deco(Protocol):
def __call__[**P, R](self, f: Callable[P, R], /) -> Callable[P, R]: ...
@overload
def trace[**P, R](f: Callable[P, R], /) -> Callable[P, R]: ...
@overload
def trace(*, level: int = ...) -> _Deco: ...The type parameters have moved from the function onto __call__ itself, and now
they bind at the moment of application. The output:
05_factory_fixed.py:34: note: Revealed type is "def (x: int) -> str"
05_factory_fixed.py:35: note: Revealed type is "def (x: int) -> str"
05_factory_fixed.py:36: error: Argument 1 to "b" has incompatible type "str"; expected "int" [arg-type]
Both forms give the same signature, and b("bad") is finally caught.
One observation along the way, which cost us a correction of our own. The
implementation under @overload originally had no annotations
(def trace(f=None, *, level=1):). mypy does not look at such an implementation
at all; pyright produced two errors each time, "Overloaded implementation is not
consistent with signature of overload". Annotating the implementation removed
both and changed nothing in the example's conclusions. An unannotated overload
implementation is invisible to mypy — worth remembering well beyond
decorators.
Version history
| Version | Change | What this means for your code |
|---|---|---|
| 3.10 | PEP 612: ParamSpec and Concatenate arrive. Before that there was nothing to express a signature-preserving decorator with, other than a callback protocol with its parameters written out by hand. | |
| 3.12 | PEP 695: the syntax def deco[**P, R] instead of a separate P = ParamSpec("P"). Measured: from 3.12, typing.ParamSpec is literally _typing.ParamSpec, a type from a module built into the interpreter (typing.ParamSpec is _typing.ParamSpec gives True, and _typing has no __file__). On 3.11, _typing has no ParamSpec attribute at all. | |
| 3.13 | PEP 696: type parameters gain defaults, ParamSpec included. Measured: TypeVar(..., default=int) raises TypeError on 3.12 and def f[T = int] is a SyntaxError there; on 3.13 both work. | |
| 3.14 | PEP 649: annotations are evaluated lazily. The practical gift for decorators is that a protocol describing the wrapper can be declared below the decorator that returns it, with no quotes and no from __future__ import annotations. Measured: on 3.13 such a definition raises NameError on the def line itself; on 3.14 it goes through, and annotationlib hands back a ForwardRef. |
What measured this
The findings in this article come from these files. Each one opens right from here, together with the record of the run: what checked it, with which command, and what came out verbatim.
bench/typing/01_naive.pybench/typing/02_paramspec.pybench/typing/03_concatenate_and_protocol.pybench/typing/04_factory_broken.pybench/typing/05_factory_fixed.pybench/typing/06_methods.pybench/typing/07_args_kwargs_pair.pybench/typing/version_matrix.py
There are two checkers: mypy 2.3.1 and pyright 1.1.413, both under CPython
3.13.7. They agreed on all seven files — same lines, same counts, same
conclusions. That is worth naming: what a tool prints is an observation here,
not a source. The typing specification comes first, and wherever this article
leans on a rule rather than on an observation, a quotation from the
specification or from a PEP stands next to it.
The version matrix comes from a single file run on four interpreters: 3.11.15,
3.12.3, 3.13.7 and 3.14.7 — all four final releases, not release candidates.
3.11 is not there for completeness: it is the only one that shows ParamSpec
has not always lived in C.
There are no timings in this article, deliberately. Typing does not affect
speed, and any table of nanoseconds here would be a table about PEP 649 rather
than about ParamSpec.
This is neither a retelling nor a separate text: everything below is taken from the article itself — its own summary, the section headings, the “actually” column and the version table. Which is why these theses cannot drift from the article.
The gist
- A decorator declared as
Callable[..., R]lets both deliberately wrong calls through, out of the three written. There are two type checkers here —mypy 2.3.1andpyright 1.1.413— and both stay silent; that is not either of them failing: PEP 612 says it outright — with an ellipsis, "we do no validation on arguments". ParamSpec(PEP 612) on the same code produces three errors on the same two lines: two about types and one about the count. Only the decorator's declaration changes; neither its body nor the calls are touched.P.argsandP.kwargsexist only as a pair, and both checkers refuse half of one. The reason is in the PEP: two valid calls may split the same set of parameters differently.- The runtime is honest while the checker is blind — and people usually expect the opposite.
inspect.signatureshows the original signature, becausefunctools.wrapsleft__wrapped__behind andinspecthops along it. Concatenatecan only prepend positional parameters. A keyword-only one cannot be added this way, and that is a limitation of the PEP itself, not of the checkers.- A decorator factory is where nearly everyone gets it wrong:
@tracekeeps the signature,@trace(level=2)loses it silently. The cure is a protocol with a generic__call__.
In fact
- It fixes introspection, and only that.
wrapssetswrapper.__wrapped__ = wrapped(Lib/functools.py:62), andinspect.signaturehops along that reference because it is declared withfollow_wrapped=True(Lib/inspect.py:3373). It tells the checker nothing:wrapsruns at decoration time, and the checker never gets as far as running anything. Measured onbench/typing/01_naive.py: both deliberately wrong calls go through in silence. There is nowrapsin that file — which is the point: it would have changed nothing. - It means “do not check”. The
typingdocumentation puts it gently (“a callable with any arbitrary parameter list would be acceptable”); PEP 612 puts it outright: an ellipsis in place of parameter types is specified to mean that we do no validation on arguments. Measured onbench/typing/01_naive.py: zero errors, exit code 0. - It is not lost, provided the decorator is declared with
Concatenate[S, P]. Measured onbench/typing/06_methods.py: for a classAwith decorated methods,reveal_type(a.m)givesdef (x: int) -> str— the receiver is bound — andreveal_type(a.chain())givesA. Both checkers agree. - This is the costliest spot in the topic. Measured: with an ordinary pair of overloads,
@tracegivesdef (x: int) -> strwhile@trace(level=2)givesdef (*Any, **Any) -> object. The loss is silent:a("bad")is flagged,b("bad")one line below is not. The cause is that a factory has to return one type while aParamSpechas to bind afresh at every application. The cure is a protocol with a generic__call__. - It cannot, and this is a limitation of PEP 612 itself rather than of the checkers. The semantics of Concatenate[X, Y, P] are that it represents the parameters represented by P with two positional-only parameters prepended. The PEP gives keyword-only parameters their own subsection explaining why this does not work, and closes it promising to revisit the question “if there is sufficient demand”. In 3.14 it was not revisited.
By version
- 3.10
- PEP 612:
ParamSpecandConcatenatearrive. Before that there was nothing to express a signature-preserving decorator with, other than a callback protocol with its parameters written out by hand.< - 3.12
- PEP 695: the syntax
def deco[**P, R]instead of a separateP = ParamSpec("P"). Measured: from 3.12,typing.ParamSpecis literally_typing.ParamSpec, a type from a module built into the interpreter (typing.ParamSpec is _typing.ParamSpecgivesTrue, and_typinghas no__file__). On 3.11,_typinghas noParamSpecattribute at all.< - 3.13
- PEP 696: type parameters gain defaults,
ParamSpecincluded. Measured:TypeVar(..., default=int)raisesTypeErroron 3.12 anddef f[T = int]is aSyntaxErrorthere; on 3.13 both work.< - 3.14
- PEP 649: annotations are evaluated lazily. The practical gift for decorators is that a protocol describing the wrapper can be declared below the decorator that returns it, with no quotes and no
from __future__ import annotations. Measured: on 3.13 such a definition raisesNameErroron thedefline itself; on 3.14 it goes through, andannotationlibhands back aForwardRef.<
What is covered
- Zero errors on two wrong calls
- What the ellipsis means
- `ParamSpec`: the same code, three errors
- Why `P.args` and `P.kwargs` come only as a pair
- The runtime is honest, the checker is blind
- When the decorator adds an argument
- What `Concatenate` cannot do
- Decorating a method: `Self` survives
- When the decorator attaches an attribute
- The factory: where nearly everyone gets it wrong
- Version history
- What measured this
Common misconceptions
functools.wraps fixes the types
It fixes introspection, and only that. wraps sets wrapper.__wrapped__ = wrapped (Lib/functools.py:62), and inspect.signature hops along that reference because it is declared with follow_wrapped=True (Lib/inspect.py:3373). It tells the checker nothing: wraps runs at decoration time, and the checker never gets as far as running anything. Measured on bench/typing/01_naive.py: both deliberately wrong calls go through in silence. There is no wraps in that file — which is the point: it would have changed nothing.
Callable[..., R] means “any arguments”
It means “do not check”. The typing documentation puts it gently (“a callable with any arbitrary parameter list would be acceptable”); PEP 612 puts it outright: an ellipsis in place of parameter types is specified to mean that we do no validation on arguments
. Measured on bench/typing/01_naive.py: zero errors, exit code 0.
Chained calls break after a decorator: Self is lost
It is not lost, provided the decorator is declared with Concatenate[S, P]. Measured on bench/typing/06_methods.py: for a class A with decorated methods, reveal_type(a.m) gives def (x: int) -> str — the receiver is bound — and reveal_type(a.chain()) gives A. Both checkers agree.
If @deco is typed correctly then @deco(level=2) is too
This is the costliest spot in the topic. Measured: with an ordinary pair of overloads, @trace gives def (x: int) -> str while @trace(level=2) gives def (*Any, **Any) -> object. The loss is silent: a("bad") is flagged, b("bad") one line below is not. The cause is that a factory has to return one type while a ParamSpec has to bind afresh at every application. The cure is a protocol with a generic __call__.
ParamSpec with Concatenate can add a keyword parameter through a decorator
It cannot, and this is a limitation of PEP 612 itself rather than of the checkers. The semantics of Concatenate[X, Y, P] are that it represents the parameters represented by P with two positional-only parameters prepended
. The PEP gives keyword-only parameters their own subsection explaining why this does not work, and closes it promising to revisit the question “if there is sufficient demand”. In 3.14 it was not revisited.
Check yourself
A decorator is declared as Callable[..., R] -> Callable[..., R], with takes_int_str(x: int, y: str) underneath it. How many errors will the checker report on takes_int_str("B", 2) and takes_int_str()?
Sources & further reading
9 SOURCES
- PEP 612 — Parameter Specification VariablesPEP. Mark Mendoza, Final, Python 3.10. The document that brought `ParamSpec` and `Concatenate` into the language, and the only place that states outright what the ellipsis means: “an ellipsis in place of parameter types is specified to mean that we do no validation on arguments”. The problem statement is about decorators in as many words: “Neither of these support forwarding the parameter types of one callable over to another callable, making it difficult to annotate function decorators”. The pair rule: “A ParamSpec captures both positional and keyword accessible parameters, but there unfortunately is no object in the runtime that captures both of these together”, and the conclusion drawn from it: “we need to make sure that these special types are only brought into the world together, and are used together, so that our usage is valid for all possible partitions”. And the semantics of concatenation: “The semantics of Concatenate[X, Y, P] are that it represents the parameters represented by P with two positional-only parameters prepended”.https://peps.python.org/pep-0612/
- Typing specification — CallablesOfficial documentation. More primary than the behaviour of any checker. On the ellipsis: “The Callable special form supports the use of `...` in place of the list of parameter types. This is a gradual form indicating that the type is consistent with any input signature”. And that it composes with concatenation: “A `...` can also be used with Concatenate”.https://typing.python.org/en/latest/spec/callables.html
- Typing specification — GenericsOfficial documentation. The `ParamSpec` section opens by naming its origin — “(Originally specified by PEP 612.)” — and reproduces the PEP's wording verbatim: both the rule that `P.args` and `P.kwargs` are used together, and “Placing keyword-only parameters between the *args and **kwargs is forbidden”. So both of this article's key claims rest on normative text, not only on what a tool prints.https://typing.python.org/en/latest/spec/generics.html
- PEP 695 — Type Parameter SyntaxPEP. Final, Python 3.12. The syntax every decorator in this article is written in: “The syntax adds support for a comma-delimited list of type parameters in square brackets after the name of the class, function, or type alias”. The double star for a `ParamSpec` is part of the grammar: `type_param: | a=NAME b=[type_param_bound] | '*' a=NAME | '**' a=NAME`.https://peps.python.org/pep-0695/
- PEP 696 — Type Defaults for Type ParametersPEP. Final, Python 3.13. “This PEP introduces the concept of type defaults for type parameters, including TypeVar, ParamSpec, and TypeVarTuple, which act as defaults for type parameters for which no type is specified.” Used here as a version boundary: on 3.12 the syntax `def f[T = int]` is a SyntaxError and `TypeVar(..., default=int)` a TypeError; on 3.13 both work.https://peps.python.org/pep-0696/
- PEP 649 — Deferred Evaluation Of Annotations Using DescriptorsPEP. Final, Python 3.14. The mechanism: “It adds a new internal mechanism for lazily computing annotations on demand, via a new object method called `__annotate__`”, and what follows from it: “This mechanism delays the evaluation of annotations expressions until the annotations are examined, which solves many circular reference problems”. That is what makes it possible to declare a protocol below the decorator that returns it.https://peps.python.org/pep-0649/
- Lib/functools.py — update_wrapperCPython source code. The line that keeps the runtime honest: `wrapper.__wrapped__ = wrapped` (`Lib/functools.py:62` on 3.13.7), with a comment next to it explaining why it comes last: “Issue #17482: set __wrapped__ last so we don't inadvertently copy it from the wrapped function when updating __dict__”. The list of copied attributes is `WRAPPER_ASSIGNMENTS` on line 33.https://github.com/python/cpython/blob/v3.13.7/Lib/functools.py
- Lib/inspect.py — signature and unwrapCPython source code. The default that makes `inspect.signature` show a function other than the one that will be called: `def signature(obj, *, follow_wrapped=True, ...)` (`Lib/inspect.py:3373` on 3.13.7). The hop along the chain is done by `unwrap` (line 764). Verified on 3.12.3, 3.13.7 and 3.14.7: with the default you see the original signature, with `follow_wrapped=False` you see `(*args, **kwargs)`.https://github.com/python/cpython/blob/v3.13.7/Lib/inspect.py
- typing — Support for type hintsOfficial documentation. The documentation puts the ellipsis more gently than the PEP does: “If a literal ellipsis `...` is given as the argument list, it indicates that a callable with any arbitrary parameter list would be acceptable”. There is no sentence on that page saying the arguments then go unchecked — that one is in PEP 612, and the gap between “any is acceptable” and “we do no validation” is precisely the subject of this article's first section.https://docs.python.org/3/library/typing.html