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 -Tis the structured output type- Agents gain capabilities through mixins:
ObservabilityMixinprovidesself.record_cost(),MemoryMixinprovidesself.history - Not every agent has every mixin.
SummarisationAgenthas cost tracking but no memory.ChatAgenthas 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 inResult[R]), the next decorator sees a differentR. Debug withreveal_type()- a special form recognized by mypy/pyright that prints the inferred type during checking (not a runtime function). P.kwargsis opaque to the type checker: At runtime,kwargsis a normal mutable dict. But the type checker won’t let you treatP.kwargsasdict[str, Any]- you can’t callkwargs.setdefault()or index into it without a type error. The type system treatsParamSpeckwargs as a sealed contract. Add defaults in the function signature itself, or usecast.@functools.wrapsdoesn’t affect types: It copies runtime metadata (__name__,__doc__), but the type annotations on your wrapper function are what the type checker sees. Use@wrapsfor introspection, rely on annotations for correctness.
TL;DR
| Scenario | Pattern |
|---|---|
| Simple passthrough | Callable[P, R] -> Callable[P, R] |
| Decorator with arguments | Outer returns Callable[[Callable[P, R]], Callable[P, R]] - triple nesting |
Decorator accesses self | Concatenate[SelfT, P] with SelfT bound to the base class |
| Decorator accesses mixin state | Protocol per decorator, bind SelfT to the protocol instead of the base class |
| Stacking decorators | Keep 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.