Decorators are everywhere in Python. Logging, retries, caching, auth checks - if you’ve built anything non-trivial, you’ve written one. But the moment you try to add type hints, things get weird. Your IDE loses autocomplete. You end up with Callable[..., Any] and pretend the problem doesn’t exist, because Callable can’t express “whatever arguments the wrapped function takes.”

PEP 612 introduced ParamSpec (Python 3.10+) to solve exactly this. This post walks through using it - from trivial cases to the genuinely painful scenarios I hit while building a multi-LLM agent backend. Read the TL;DR section for a quick summary.


Level 1: Simple passthrough decorator

The classic timing decorator. It doesn’t change arguments or return types - just wraps the call.

import time
from functools import wraps
from typing import Callable, ParamSpec, TypeVar

P = ParamSpec("P")
R = TypeVar("R")


def timeit(func: Callable[P, R]) -> Callable[P, R]:
    @wraps(func)
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
        start = time.perf_counter()
        result = func(*args, **kwargs)
        print(f"{func.__name__} took {time.perf_counter() - start:.3f}s")
        return result

    return wrapper


@timeit
def fetch_data(url: str, timeout: int = 30) -> dict[str, str]: ...

ParamSpec("P") captures the entire parameter signature. P.args and P.kwargs are special forms that can only appear together in a function signature. The type checker sees fetch_data as (url: str, timeout: int = 30) -> dict[str, str] - signature fully preserved.


Level 2: Decorator with arguments

@retry(times=3) means retry(times=3) returns the actual decorator. Triple nesting.

from functools import wraps
from typing import Callable, ParamSpec, TypeVar

P = ParamSpec("P")
R = TypeVar("R")


def retry(times: int = 3) -> Callable[[Callable[P, R]], Callable[P, R]]:
    def decorator(func: Callable[P, R]) -> Callable[P, R]:
        @wraps(func)
        def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
            for attempt in range(times):
                try:
                    return func(*args, **kwargs)
                except Exception:
                    if attempt == times - 1:
                        raise
            raise RuntimeError("unreachable")

        return wrapper

    return decorator


@retry(times=5)
def simple(a: str) -> dict[str, str]: ...


@retry(times=5)
def complicated(a: int, b: int, c: str = "default") -> int: ...

The outer function returns Callable[[Callable[P, R]], Callable[P, R]] - a function that takes a callable and returns one with the same signature. Type checkers handle this well.


Level 3: Method decorators

ParamSpec captures self automatically. The same timeit decorator from Level 1 works on methods without changes.

import time
from functools import wraps
from typing import Callable, ParamSpec, TypeVar

P = ParamSpec("P")
R = TypeVar("R")


def timeit(func: Callable[P, R]) -> Callable[P, R]:
    @wraps(func)
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
        start = time.perf_counter()
        result = func(*args, **kwargs)
        print(f"{func.__name__} took {time.perf_counter() - start:.3f}s")
        return result

    return wrapper


class UserService:
    @timeit
    def get_user(self, user_id: int) -> dict[str, int]: ...

P captures (self: UserService, user_id: int). The type checker is happy, self.get_user(42) has correct autocomplete. No special handling needed.

But this breaks when the decorator needs to access self.


Level 4: Decorators that access self

If the decorator body needs self - for logging via self.logger, checking self.config, etc. - you need Concatenate.

The problem: P is atomic. You can’t decompose it to “pull out the first argument.” typing.Concatenate solves this by letting you prepend specific types to a ParamSpec:

import logging
from functools import wraps
from typing import Callable, Concatenate, ParamSpec, TypeVar

P = ParamSpec("P")
R = TypeVar("R")


class Service:
    logger: logging.Logger


SelfT = TypeVar("SelfT", bound=Service)


def with_logging(
    func: Callable[Concatenate[SelfT, P], R],
) -> Callable[Concatenate[SelfT, P], R]:
    @wraps(func)
    def wrapper(self: SelfT, *args: P.args, **kwargs: P.kwargs) -> R:
        self.logger.info(f"Calling {func.__name__}")  # self is typed as SelfT
        return func(self, *args, **kwargs)

    return wrapper


class UserService(Service):
    @with_logging
    def get_user(self, user_id: int) -> dict[str, int]: ...

Mental model for Concatenate:

Original:  def get_user(self, user_id: int) -> dict[str, int]
                        ^^^^  ^^^^^^^^^^^^
Concatenate[SelfT, P] matches as:
  SelfT = UserService (bound to Service)
  P     = (user_id: int)

Concatenate prepends SelfT to P, so the full signature is reconstructed as (self: SelfT, *P.args, **P.kwargs). The decorator can use self.logger because SelfT is bound to Service.


Level 5: When the base class isn’t enough

Here’s where things get interesting. Consider an agent backend where:

  • Agent[T] is a generic base class - T is the structured output type
  • Agents gain capabilities through mixins: ObservabilityMixin provides self.record_cost(), MemoryMixin provides self.history
  • Not every agent has every mixin. SummarisationAgent has cost tracking but no memory. ChatAgent has both.
  • Decorators need access to self, but each decorator needs attributes from a different mixin

The naive approach - AgentT = TypeVar("AgentT", bound="Agent[Any]") for every decorator - breaks immediately. Agent doesn’t have record_cost(). You could dump every attribute onto the base class, but that defeats the purpose of mixins.

The solution: each decorator declares a Protocol for the self it needs, and binds its TypeVar to that Protocol. @track_cost requires HasObservability. @inject_history requires HasMemory. @validate_output only needs the base Agent.

The type checker then enforces correctness at the decorator application site. Slapping @inject_history on a SummarisationAgent (which lacks MemoryMixin) is a type error - exactly the bug you want caught at dev time, not when self.history blows up in production.

from abc import ABC, abstractmethod
from dataclasses import dataclass
from functools import wraps
from typing import Any, Callable, Concatenate, Generic, ParamSpec, Protocol, TypeVar

# Single set of type variables - shared across all decorators
P = ParamSpec("P")
R = TypeVar("R")
T = TypeVar("T")


@dataclass
class Summary:
    condensed: str


@dataclass
class ChatResponse:
    reply: str


class LLMClient: ...


class ObservabilityMixin:
    def record_cost(self, tokens: int) -> None: ...


class MemoryMixin:
    history: list[dict[str, str]] = []


class HasObservability(Protocol):
    def record_cost(self, tokens: int) -> None: ...


class HasMemory(Protocol):
    history: list[dict[str, str]]


# TypeVars bound to protocols - not to the base class
ObservableT = TypeVar("ObservableT", bound=HasObservability)
MemoryAwareT = TypeVar("MemoryAwareT", bound=HasMemory)
AgentT = TypeVar("AgentT", bound="Agent[Any]")


def track_cost(
    func: Callable[Concatenate[ObservableT, P], R],
) -> Callable[Concatenate[ObservableT, P], R]:
    """Record token usage via self.record_cost. Works on any agent with ObservabilityMixin."""

    @wraps(func)
    def wrapper(self: ObservableT, *args: P.args, **kwargs: P.kwargs) -> R:
        result = func(self, *args, **kwargs)
        self.record_cost(tokens=42)
        return result

    return wrapper


def inject_history(
    func: Callable[Concatenate[MemoryAwareT, P], R],
) -> Callable[Concatenate[MemoryAwareT, P], R]:
    """Prepend conversation history. Only works on agents with MemoryMixin."""

    @wraps(func)
    def wrapper(self: MemoryAwareT, *args: P.args, **kwargs: P.kwargs) -> R:
        # decorator reads self.history - only available via MemoryMixin
        _ = self.history
        return func(self, *args, **kwargs)

    return wrapper


def validate_output(
    func: Callable[Concatenate[AgentT, P], R],
) -> Callable[Concatenate[AgentT, P], R]:
    """Retry on malformed output. Only needs base Agent attributes."""

    @wraps(func)
    def wrapper(self: AgentT, *args: P.args, **kwargs: P.kwargs) -> R:
        result = func(self, *args, **kwargs)
        for _ in range(self.max_validation_retries - 1):
            if self.is_valid(result):
                return result
            result = func(self, *args, **kwargs)
        return result

    return wrapper


class Agent(ABC, Generic[T]):
    llm: LLMClient
    max_validation_retries: int = 3

    def is_valid(self, result: Any) -> bool: ...

    @abstractmethod
    def run(self, prompt: str) -> T: ...


class SummarisationAgent(ObservabilityMixin, Agent[Summary]):
    """Has cost tracking (ObservabilityMixin), but no memory."""

    @track_cost  # ✅ Has ObservabilityMixin → satisfies HasObservability
    @validate_output
    def run(self, prompt: str) -> Summary:
        return Summary(condensed=prompt)


class ChatAgent(ObservabilityMixin, MemoryMixin, Agent[ChatResponse]):
    """Has both cost tracking and conversation memory."""

    @track_cost  # ✅ Has ObservabilityMixin → satisfies HasObservability
    @inject_history  # ✅ Has MemoryMixin → satisfies HasMemory
    @validate_output
    def run(self, prompt: str) -> ChatResponse:
        return ChatResponse(reply=prompt)


class AgentWithoutMemory(Agent[Summary]):
    """
    ❌ Type error
    Argument of type "(self: Self@AgentWithoutMemory, prompt: str) -> Summary" cannot be assigned to parameter "func" of type "(MemoryAwareT@inject_history, **P@inject_history) -> R@inject_history" in function "inject_history"
    Type "(self: Self@AgentWithoutMemory, prompt: str) -> Summary" is not assignable to type "(MemoryAwareT@inject_history, **P@inject_history) -> R@inject_history"
      Parameter 1: type "MemoryAwareT@inject_history" is incompatible with type "Self@AgentWithoutMemory"
        "HasMemory*" is not assignable to "AgentWithoutMemory"basedpyrightreportArgumentType
    """

    @inject_history
    def run(self, prompt: str) -> Summary:
        return Summary(condensed=prompt)

SummarisationAgent uses @track_cost and @validate_output - it has ObservabilityMixin so it satisfies HasObservability, and it extends Agent so it satisfies the base bound. ChatAgent additionally uses @inject_history because it has MemoryMixin. AgentWithoutMemory at the bottom shows what happens when you get it wrong - pyright rejects it because Agent[Summary] alone doesn’t satisfy the HasMemory protocol.


Common pitfalls

  • TypeVar shadowing across modules: If decorators in different modules each define their own R = TypeVar("R"), the type checker may fail to unify them when stacked. Define a single set of TypeVars in one module and import everywhere.
  • Stacking order breaks when return types change: If all decorators have identical signatures (Callable[Concatenate[AgentT, P], R] -> Callable[Concatenate[AgentT, P], R]), order doesn’t matter. If one changes the return type (wrapping in Result[R]), the next decorator sees a different R. Debug with reveal_type() - a special form recognized by mypy/pyright that prints the inferred type during checking (not a runtime function).
  • P.kwargs is opaque to the type checker: At runtime, kwargs is a normal mutable dict. But the type checker won’t let you treat P.kwargs as dict[str, Any] - you can’t call kwargs.setdefault() or index into it without a type error. The type system treats ParamSpec kwargs as a sealed contract. Add defaults in the function signature itself, or use cast.
  • @functools.wraps doesn’t affect types: It copies runtime metadata (__name__, __doc__), but the type annotations on your wrapper function are what the type checker sees. Use @wraps for introspection, rely on annotations for correctness.

TL;DR

ScenarioPattern
Simple passthroughCallable[P, R] -> Callable[P, R]
Decorator with argumentsOuter returns Callable[[Callable[P, R]], Callable[P, R]] - triple nesting
Decorator accesses selfConcatenate[SelfT, P] with SelfT bound to the base class
Decorator accesses mixin stateProtocol per decorator, bind SelfT to the protocol instead of the base class
Stacking decoratorsKeep signatures identical across all decorators. Share TypeVars at module level

What other decorator typing issues have you run into? I’m curious what I’ve missed.