For AI agentsA Markdown index of these docs is at /llms.txt. Every page is also Markdown: add .md to its address.

Agent Rules / Dispatch and reflection

Do decorators hide function signatures from coding agents?

Rule AR-12

Use functools.wraps / preserve signatures in decorators; don't let decorators replace a function with a different shape.

A decorator rebinds the function's name to whatever the decorator returns. If the return value is a wrapper with a generic (*args, **kwargs) signature, every caller of the decorated name now sees the wrapper's shape, not the original's. functools.wraps fixes the runtime metadata, and typing the decorator with ParamSpec fixes what the checker sees. A decorator that changes the shape on purpose should say so in its type.

Why it happens

Python. The language reference says decorator expressions are evaluated when the function is defined, the result must be a callable that is invoked with the function as its only argument, and the returned value is bound to the function name in place of the function. @f1(arg) over @f2 over def func is roughly func = f1(arg)(f2(func)).

So the name func now refers to the wrapper. functools.wraps (a form of update_wrapper) copies __module__, __name__, __qualname__, __annotations__, __type_params__ and __doc__ from the original onto the wrapper, merges its __dict__, and sets wrapper.__wrapped__ to the original. inspect.signature follows __wrapped__ by default (follow_wrapped=True), so runtime introspection reports the original's parameters. That is the runtime fix.

A static checker doesn't run the code. The typing docs say that before ParamSpec, the workaround was a TypeVar bound to Callable[..., Any], which loses checking inside the wrapper and often needs cast(). PEP 612 describes the same gap: with ... as the parameter list, the checker does no validation on arguments. Annotating the decorator as Callable[P, T] -> Callable[P, T] with *args: P.args, **kwargs: P.kwargs keeps the wrapped function's signature visible. Concatenate[X, P] lets a decorator add or remove leading parameters and say so, as in the docs' with_lock example, where callers no longer pass the lock.

TypeScript and JavaScript. A higher-order function returns a new function, and its return type decides what callers see. TypeScript's generic rest parameters (TypeScript 3.0) let a wrapper be typed <A extends unknown[], R>(fn: (...args: A) => R) => (...args: A) => R, which keeps the parameter list and the result. A wrapper typed (...args: any[]) => any replaces them with any.

Bad and good

A caching decorator on a pricing function.

python# bad: unannotated decorator; strict mode flags its body, and the wrapper's shape # reaches any reader that does not infer through it def cached(fn): memo = {} def wrapper(*args, **kwargs): key = (args, tuple(sorted(kwargs.items()))) if key not in memo: memo[key] = fn(*args, **kwargs) return memo[key] return wrapper @cached def quote(sku: str, qty: int, region: str = "EU") -> Price: ... quote("A1", "ten") # pyright 1.1.414 reports this (reportArgumentType); fails at runtime
typescript// bad: the wrapper's shape replaces the original's function cached(fn: (...args: any[]) => any) { const memo = new Map<string, unknown>(); return (...args: any[]) => { const key = JSON.stringify(args); if (!memo.has(key)) memo.set(key, fn(...args)); return memo.get(key); }; } const quote = cached((sku: string, qty: number, region = "EU"): Price => lookup(sku, qty, region)); quote("A1", "ten"); // accepted: quote is (...args: any[]) => any
python# good: wraps for runtime, ParamSpec for the checker from collections.abc import Callable from functools import wraps from typing import ParamSpec, TypeVar P = ParamSpec("P") T = TypeVar("T") def cached(fn: Callable[P, T]) -> Callable[P, T]: memo: dict[tuple, T] = {} @wraps(fn) def wrapper(*args: P.args, **kwargs: P.kwargs) -> T: key = (args, tuple(sorted(kwargs.items()))) if key not in memo: memo[key] = fn(*args, **kwargs) return memo[key] return wrapper @cached def quote(sku: str, qty: int, region: str = "EU") -> Price: ...
typescript// good: the parameter list and result pass through function cached<A extends unknown[], R>(fn: (...args: A) => R): (...args: A) => R { const memo = new Map<string, R>(); return (...args: A): R => { const key = JSON.stringify(args); if (!memo.has(key)) memo.set(key, fn(...args)); return memo.get(key) as R; }; } const quote = cached((sku: string, qty: number, region = "EU"): Price => lookup(sku, qty, region));

After the good version, quote("A1", "ten") is a type error in both languages, and renaming qty in the original shows up at its callers. In the TypeScript bad version, quote is (...args: any[]) => any and the bad call passes.

The Python bad version behaves differently under pyright. Checked with pyright 1.1.414 in strict mode: for an unannotated passthrough decorator that uses functools.wraps, pyright keeps the original signature on the decorated name, (sku: str, qty: int, region: str = "EU") -> float, and reports quote("A1", "ten") as reportArgumentType. The call is not hidden. Strict mode does flag the decorator body itself with unknown-type errors (reportUnknownParameterType, reportUnknownArgumentType). So wraps and ParamSpec matter for runtime metadata and for decorators that really change the shape; pyright already preserves an unannotated passthrough. Other checkers and tools that read the source without inferring through the decorator can still see only the wrapper.

Trade-offs

  • Decorators are good at cross-cutting behaviour. Caching, retries, timing, auth checks and route registration read well as one line above the function. The cost is the one in this page: the type has to be carried through on purpose.
  • **wraps alone is not enough for type checkers.** It copies runtime attributes (the functools docs list them). Static checkers need the ParamSpec annotation. Use both.
  • Shape-changing decorators are fine when typed. A decorator that injects a first argument, such as a database session, should use Concatenate so the decorated function's callers see the shorter list. The shape change is then in the type, not hidden.
  • Class decorators and decorators that return a class or non-function need their own return types. Callable[P, T] is for functions.
  • **__wrapped__ has a debug use.** The functools docs note it can reach the original, for example to bypass lru_cache. Tests can use it. Production code shouldn't depend on it.
  • Stacked decorators multiply the problem. One decorator that returns an unknown or Any type in the stack hides the signature from every one above it. Type each.

Enforce it

ToolSettingWhat it catchesChecked
pyrightreportUntypedFunctionDecorator (none in basic and standard, error in strict)a decorator without type annotations, which obscures the decorated function's type2026-10-30
pyrightreportMissingParameterType, reportUnknownParameterType (error in strict)decorator and wrapper parameters without annotations2026-10-30
typingParamSpec, Concatenate (PEP 612, Python 3.10)the annotation that carries signatures through2026-10-30
typescript-eslintno-explicit-any (in recommended, ignoreRestArgs default false)(...args: any[]) => any wrappers2026-10-30
typescript-eslintno-unsafe-call, no-unsafe-argument (in recommended-type-checked)calls through values typed any2026-10-30
tsconfignoImplicitAny (on with strict)wrapper parameters left unannotated2026-10-30

No rule in the pages checked reports a missing functools.wraps. That check is a review item for now.

Rule block for AGENTS.md / CLAUDE.md

markdown- Every decorator type-annotates its input and output so the decorated function keeps its signature: Python `Callable[P, T] -> Callable[P, T]` (ParamSpec) and `@functools.wraps(fn)` on the inner function; TypeScript `<A extends unknown[], R>(fn: (...args: A) => R) => (...args: A) => R`. - A decorator that adds or removes parameters uses `Concatenate` and documents it. - Before changing a decorated function's signature, check the decorator's type still passes it through.

Edge cases

  • Methods. A decorator applied to a method receives the plain function, with self as its first parameter. ParamSpec captures self along with the rest.
  • Decorators with arguments (@retry(times=3)) are a function returning a decorator. Type both layers.
  • Async functions. The wrapper must be async def and await the call, or the return type changes to a coroutine. PEP 612's own example is an async logging decorator typed with Callable[..., Awaitable[R]] before ParamSpec.
  • **lru_cache and cache** come from the standard library with their own wrapper types. Use them rather than a hand-written cache when they fit.
  • React higher-order components are the UI version of this problem; see AR-36 and CH-04.
  • Framework decorators (route registration, signals) return the function unchanged and record it elsewhere. That is a registry write, so AR-09 applies.

References

  • Python compound statements, function definitions and decorators: https://docs.python.org/3/reference/compound_stmts.html
  • functools.wraps, update_wrapper, __wrapped__: https://docs.python.org/3/library/functools.html
  • inspect.signature and follow_wrapped: https://docs.python.org/3/library/inspect.html
  • Python typing, ParamSpec, Concatenate: https://docs.python.org/3/library/typing.html
  • PEP 612: https://peps.python.org/pep-0612/
  • Pyright configuration: https://raw.githubusercontent.com/microsoft/pyright/main/docs/configuration.md
  • TypeScript 3.0 release notes (generic rest parameters): https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-0.html
  • TSConfig reference: https://www.typescriptlang.org/tsconfig/
  • typescript-eslint: https://typescript-eslint.io/rules/no-explicit-any/ · https://typescript-eslint.io/rules/no-unsafe-call/ · https://typescript-eslint.io/rules/no-unsafe-argument/

Changelog

  • 2026-10-30: draft 1.
  • 2026-10-30: tool checks resolved (pyright 1.1.414, tsc 7.0.2)
  • AR-11 Why does my agent break calls that go through **kwargs?
  • AR-09 How should plugin registries be written for coding agents?
  • AR-36 Do higher-order components hide props from coding agents?

Last verified

For agentsthis page as Markdown/agent-readable/ar-12.mdllms.txt