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:

  1. An argument of type Arg1Type.
  2. An argument of type Arg2Type.
  3. Followed by whatever remaining arguments are captured by the ParamSpec instance P.

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 Error

In this scenario:

Key Rules and Limitations