What is PEP 612?
PEP 612: Parameter Specification Variables (Python 3.10) adds ParamSpec and Concatenate to the typing system. They let a decorator declare “this returns a function with the same parameters as the one it wrapped,” which ordinary type variables from PEP 484 cannot express.
Why did decorators erase signatures?
Before PEP 612, the best available type for a generic decorator was Callable[..., R]. The ... throws away everything about the parameters, so a type checker accepts any arguments to the decorated function:
from collections.abc import Callable
from typing import TypeVar
R = TypeVar("R")
def logged(func: Callable[..., R]) -> Callable[..., R]: ...
@logged
def greet(name: str, excited: bool = False) -> str: ...
greet(42) # no error: the checker lost the signatureEvery decorated function opted out of argument checking, and the checker never said so.
How does ParamSpec preserve the signature?
A ParamSpec captures the wrapped function’s full parameter list, names and defaults included, and carries it to the returned function:
from collections.abc import Callable
from typing import ParamSpec, TypeVar
P = ParamSpec("P")
R = TypeVar("R")
def logged(func: Callable[P, R]) -> Callable[P, R]:
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
print("calling", func.__name__)
return func(*args, **kwargs)
return wrapper
@logged
def greet(name: str, excited: bool = False) -> str:
return f"hi {name}{'!' if excited else ''}"
greet("world", excited=True) # ok
greet(42) # caughtmypy reports the second call as Argument 1 to "greet" has incompatible type "int"; expected "str". Pyright and ty catch it the same way.
What does Concatenate add?
Concatenate types decorators that add or remove leading arguments. A decorator that opens a database connection and passes it as the first argument keeps the rest of the signature intact:
from collections.abc import Callable
from typing import Concatenate
import sqlite3
def with_conn[**P, R](func: Callable[Concatenate[sqlite3.Connection, P], R]) -> Callable[P, R]:
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
conn = sqlite3.connect("app.db")
try:
return func(conn, *args, **kwargs)
finally:
conn.close()
return wrapper
@with_conn
def count_rows(conn: sqlite3.Connection, table: str) -> int:
return conn.execute(f"SELECT count(*) FROM {table}").fetchone()[0]
count_rows("users") # ok: the decorator supplies conn
count_rows(table=123) # caught: str expectedCallers never see the conn parameter; the checker validates everything else.
Where do you get ParamSpec?
ParamSpec and Concatenate live in typing from Python 3.10, and in typing_extensions for older versions. On Python 3.12+, PEP 695 type parameter syntax declares one inline with def logged[**P, R](...), as the with_conn example shows, with no ParamSpec("P") declaration at all.
Learn More
- PEP 612: Parameter Specification Variables
- ParamSpec in the typing documentation
- What is PEP 681? covers the other big “make the checker understand library magic” PEP