# 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 tab
# 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
```

```ts tab
// 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 tab
# 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: ...
```

```ts tab
// 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
| Tool | Setting | What it catches | Checked |
|---|---|---|---|
| pyright | `reportUntypedFunctionDecorator` (none in basic and standard, error in strict) | a decorator without type annotations, which obscures the decorated function's type | 2026-10-30 |
| pyright | `reportMissingParameterType`, `reportUnknownParameterType` (error in strict) | decorator and wrapper parameters without annotations | 2026-10-30 |
| typing | `ParamSpec`, `Concatenate` (PEP 612, Python 3.10) | the annotation that carries signatures through | 2026-10-30 |
| typescript-eslint | `no-explicit-any` (in `recommended`, `ignoreRestArgs` default `false`) | `(...args: any[]) => any` wrappers | 2026-10-30 |
| typescript-eslint | `no-unsafe-call`, `no-unsafe-argument` (in `recommended-type-checked`) | calls through values typed `any` | 2026-10-30 |
| tsconfig | `noImplicitAny` (on with `strict`) | wrapper parameters left unannotated | 2026-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)

## Related

- [AR-11: Why does my agent break calls that go through **kwargs?](https://beta.llmgraph.in/agent-readable/ar-11.md)
- [AR-09: How should plugin registries be written for coding agents?](https://beta.llmgraph.in/agent-readable/ar-09.md)
- [AR-36: Do higher-order components hide props from coding agents?](https://beta.llmgraph.in/agent-readable/ar-36.md)

Last verified 2026-10-30
