Using typing.Concatenate with ParamSpec in Python
Python's typing.Concatenate works in conjunction with
typing.ParamSpec to model callables whose parameter lists
are modified by prepending one or more arguments. Introduced in Python
3.10 via PEP 612, it allows static type checkers like mypy and Pyright
to track functions that dynamically accept additional leading
parameters, such as framework-injected dependencies, request objects, or
bound self/cls references, without losing the
signature and types of the remaining arguments.
The Core Problem
Prior to ParamSpec and Concatenate,
higher-order functions like decorators faced a trade-off. Using
Callable[..., ReturnType] stripped all information about
parameter names and types, breaking autocompletion and type checking.
While ParamSpec captures an entire parameter list
(*args, **kwargs), it treats the signature as
an indivisible unit. You cannot simply use
Callable[[int, P], R] because ParamSpec
represents the complete argument specification, not a single type.
typing.Concatenate bridges this gap by explicitly
expressing the addition of positional arguments to the front of an
existing ParamSpec.
How typing.Concatenate
Works
Concatenate is used exclusively as the first argument of
a Callable type hint:
Callable[Concatenate[Arg1Type, Arg2Type, P], ReturnType]When evaluated by a type checker, this expression indicates a callable that accepts:
- An argument of type
Arg1Type. - An argument of type
Arg2Type. - Followed by whatever remaining arguments are captured by the
ParamSpecinstanceP.
The last argument passed to Concatenate must always be a
ParamSpec variable.
Practical Example: Context Injection
A common design pattern involves decorators that prepend a context or configuration object before calling the original function.
from typing import Callable, ParamSpec, TypeVar, Concatenate
P = ParamSpec("P")
R = TypeVar("R")
class RequestContext:
user_id: int
def with_context(
func: Callable[P, R]
) -> Callable[Concatenate[RequestContext, P], R]:
def wrapper(ctx: RequestContext, *args: P.args, **kwargs: P.kwargs) -> R:
print(f"Executing for user: {ctx.user_id}")
return func(*args, **kwargs)
return wrapper
# Target function
def send_email(recipient: str, subject: str) -> bool:
return True
# The decorated function now expects a RequestContext as its first argument
secure_send = with_context(send_email)
ctx = RequestContext()
ctx.user_id = 42
# Correct usage: type checker verifies all arguments
secure_send(ctx, "user@example.com", "Hello!")
# Incorrect usage: type checker flags missing 'subject' or missing 'ctx'
# secure_send("user@example.com", "Hello!") # Type ErrorIn this scenario:
send_emailhas the signature(recipient: str, subject: str) -> bool.Pcaptures(recipient: str, subject: str).with_contextreturns a function with the signature(RequestContext, recipient: str, subject: str) -> bool.
Key Rules and Limitations
- Positional-Only Placement:
Concatenateonly prepends positional arguments. It cannot append parameters to the end of aParamSpecor insert keyword-only parameters. - Placement Restriction:
Concatenatecannot exist on its own; it is only valid inside the parameter position oftyping.Callable. - Terminal Requirement: The final item inside
Concatenate[...]must be aParamSpec(e.g.,Concatenate[int, str, P]). Placing standard types at the end will raise a typing error.