# How should I type callbacks so agents can follow them?

> Rule AR-14: Type callback and handler parameters (Callable[...], function types), not object/Function.

A callback is a function passed in as a value, and the only place its shape is written down is the parameter that receives it. If that parameter is `Callable[..., Any]`, `object` or `Function`, every handler passed to it is accepted whatever it takes, and the call inside is unchecked. Write the full signature once at the receiving end, and the checker verifies each handler you register and each call you make.

## Why it happens
**Python.** The typing docs define the full form: `Callable[[int], str]` "signifies a function that takes a single parameter of type `int` and returns a `str`". The subscription always takes exactly two values, the argument list and the return type. The escape is the ellipsis: "If a literal ellipsis `...` is given as the argument list, it indicates that a callable with any arbitrary parameter list would be acceptable." The typing specification calls it "a gradual form indicating that the type is consistent with any input signature". So `Callable[..., None]` accepts a handler with the wrong arity, and the call `handler(event, retry=True)` inside the receiver is never checked against the handlers it will run.

`object` is worse for a different reason. It is a safe type, so the checker rejects calling it at all, and the usual response is to add `cast` or `Any` to get the call through (AR-13).

Two rules in the specification matter when you write the type. "Parameters specified using `Callable` are assumed to be positional-only", so a handler that must be called with a keyword needs a callback protocol, which the specification describes as a way to express callbacks "impossible to express using the `Callable` special form", such as keyword parameters, variadics, defaults and overloads. And when a wrapper must forward whatever signature it was given, the tool for that is `ParamSpec` (AR-11, AR-12).

**TypeScript.** The `Function` type is not a function type with unknown details. The typescript-eslint docs say it "accepts calls with any number of arguments and returns `any`", and also accepts classes or objects that have `Function`'s properties. A parameter typed `Function` loses the parameter list and the return type in one step. The explicit form `(event: PaymentEvent) => void` keeps both.

Two TypeScript behaviours are worth knowing. The compiler compares function parameters loosely by default: the handbook says assignment succeeds "if either the source parameter is assignable to the target parameter, or vice versa", calls this "unsound", and says the `strictFunctionTypes` flag makes TypeScript flag it. The tsconfig page adds that this flag "only applies to functions written in *function* syntax, not to those in *method* syntax". So a callback declared as a method (`onPaid(e: PaymentEvent): void`) is checked more loosely than one declared as a property (`onPaid: (e: PaymentEvent) => void`). The handbook also notes that optional and required parameters are interchangeable when comparing functions, and shows a call where "x and y are actually required" but the handler declares them optional.

## Bad and good
A webhook module lets callers register a handler per event type.

```python tab
# bad: webhooks.py
from typing import Any, Callable

_handlers: dict[str, list[Callable[..., Any]]] = {}

def on(event_type: str, handler: Callable[..., Any]) -> None:
    _handlers.setdefault(event_type, []).append(handler)

def dispatch(event_type: str, event: PaymentEvent) -> None:
    for handler in _handlers.get(event_type, []):
        handler(event, retry=False)       # does every handler take retry?
```

```ts tab
// bad: webhooks.ts
const handlers: Record<string, Function[]> = {};

export function on(eventType: string, handler: Function): void {
  (handlers[eventType] ??= []).push(handler);
}

export function dispatch(eventType: string, event: PaymentEvent): void {
  for (const handler of handlers[eventType] ?? []) {
    handler(event, { retry: false });     // does every handler take a second argument?
  }
}
```

An agent adds a `refund_handler(event)` and registers it. Another asked to drop the `retry` argument edits `dispatch` and updates the handlers it can find by name. The handler registered through `on(...)` isn't a call the agent can see, and nothing flags a handler whose parameters no longer match.

```python tab
# good: webhooks.py
from typing import Callable, Protocol

Handler = Callable[[PaymentEvent], None]

class RetryingHandler(Protocol):
    def __call__(self, event: PaymentEvent, *, retry: bool) -> None: ...

_handlers: dict[str, list[Handler]] = {}

def on(event_type: str, handler: Handler) -> None:
    _handlers.setdefault(event_type, []).append(handler)

def dispatch(event_type: str, event: PaymentEvent) -> None:
    for handler in _handlers.get(event_type, []):
        handler(event)
```

```ts tab
// good: webhooks.ts
export type Handler = (event: PaymentEvent) => void;

const handlers: Record<string, Handler[]> = {};

export function on(eventType: string, handler: Handler): void {
  (handlers[eventType] ??= []).push(handler);
}

export function dispatch(eventType: string, event: PaymentEvent): void {
  for (const handler of handlers[eventType] ?? []) {
    handler(event);
  }
}
```

Registering a handler with the wrong shape is now an error at the `on(...)` call. Changing `Handler` is an error at every handler passed in. The `RetryingHandler` protocol is there for the case `Callable` can't express, a handler called with a keyword argument. Use it as the parameter type where that is the contract. The registry itself is the subject of AR-09, and typed event names are AR-39.

## Trade-offs and when a loose callback type is acceptable
- **`Callable[..., T]` is the right type for a wrapper that forwards its argument list unchanged**, such as a retry helper or a timing decorator. Prefer `ParamSpec` there so the wrapped function's signature survives (AR-12), and use the ellipsis only when you can't.
- **The strict form ties the receiver to one shape.** Handlers with extra context need a wider `event` type, a second registry or a closure. That is a design cost, and it is the reason the contract is worth stating.
- **Variadic or heterogeneous callbacks** (middleware chains, test hooks) are real. Type the common prefix and document the rest, or use a callback protocol for the exact shape.
- **Returning a value matters.** A handler typed `-> None` in Python or `=> void` in TypeScript accepts any return value in TypeScript (a function returning a value is assignable to `() => void`). Checked with tsc 7.0.2 under `--strict`: both assignments of a value-returning function to a `() => void` type were accepted. If the return is read, type it.
- **Named type aliases (`Handler`) are worth it** once the signature is used in more than one place, because a search for the alias finds every receiver.

## Enforce it
| Tool | Setting | What it catches | Checked |
|---|---|---|---|
| typescript-eslint | `no-unsafe-function-type` (in `recommended`) | the built-in `Function` type; the docs suggest `(text: string) => number` style types | 2026-10-30 |
| typescript-eslint | `no-unsafe-call`, `no-unsafe-argument` (in `recommended-type-checked`, need type information) | calling an `any` value, or passing one as an argument | 2026-10-30 |
| typescript-eslint | `no-explicit-any` (in `recommended`) | `(...args: any[]) => any` and similar | 2026-10-30 |
| tsconfig | `strictFunctionTypes` (on under `strict`) | handler parameter types that don't match, for function-syntax types | 2026-10-30 |
| ruff | `ANN401` any-type | `Any` as the argument or return type of a function, including `Any` in the receiver's own signature. It does not look inside `Callable[..., Any]` (checked with ruff 0.17.3: one `ANN401` on an `x: Any` parameter, none on `Callable[..., Any]`) | 2026-10-30 |
| pyright | `reportMissingParameterType`, `reportUnknownParameterType`, `reportUnknownLambdaType` (error in strict) | unannotated callback parameters, and lambdas whose parameter or return types are unknown | 2026-10-30 |
| mypy | `--disallow-untyped-defs`, `--disallow-any-generics` (in `--strict`), `--disallow-any-explicit` (not in `--strict`) | untyped handlers; a bare generic; written `Any` inside a `Callable` | 2026-10-30 |

I found no rule in the ruff or pyright pages checked that flags `Callable[..., Any]` by name. `--disallow-any-explicit` in mypy does reject `Any` in annotations and generic parameters, which covers it. For `Function` in TypeScript, `no-unsafe-function-type` is a direct hit.

## Rule block for AGENTS.md / CLAUDE.md
```markdown
## Callbacks and handlers
- Type every callback parameter with its full signature: `Callable[[Event], None]` in Python, `(event: Event) => void` in TypeScript. Name the type once and reuse it.
- Don't use `Callable[..., Any]`, `object`, `Function` or `(...args: any[]) => any` for a handler. Use `ParamSpec`/`Parameters<>` for a wrapper that forwards a signature.
- A handler that needs keyword arguments uses a Protocol with `__call__`.
- When changing a handler's signature, change the alias, then fix every registration site the checker reports.
```

## Edge cases
- **A bare `Callable` with no subscript** is flagged in strict settings. Pyright strict reports `reportMissingTypeArgument` ("Expected type arguments for generic class") and also marks the parameter as partially unknown. Mypy with `--strict` (through `--disallow-any-generics`) reports error code `type-arg` ("Missing type arguments for generic type "Callable""). Outside those settings, check what your tool does with it.
- **Lambdas** passed as handlers are checked against the declared type when it is stated, and inferred from it. With `Callable[..., Any]` they are inferred from nothing.
- **Method syntax versus property syntax in TypeScript** changes how strictly parameters are compared (see above). For callback properties on an interface, write `onPaid: (e: PaymentEvent) => void`.
- **Bound methods and `functools.partial`** can be passed where a `Callable` is expected, and the checker uses the bound signature. A `partial` over a looked-up attribute hides the call (AR-07).
- **Async handlers.** A `Callable[[E], None]` is not an `async def` handler, whose return is a coroutine. Write `Callable[[E], Awaitable[None]]` or the TypeScript `(e: E) => Promise<void>` and await it.
- **Event emitters with string event names** are the next layer up. See AR-39 and CH-08.

## What LLMGRAPH shows
A handler registered through a parameter prints `passed as a value in 1 place: calls through it are not visible`, typed or not.

## References
- Python typing docs, annotating callable objects: https://docs.python.org/3/library/typing.html#annotating-callable-objects
- Typing specification, callables (`Callable`, `...`, callback protocols, `Concatenate`): https://typing.python.org/en/latest/spec/callables.html
- TypeScript handbook, type compatibility (function parameter bivariance, optional parameters): https://www.typescriptlang.org/docs/handbook/type-compatibility.html
- TSConfig reference (`strictFunctionTypes`): https://www.typescriptlang.org/tsconfig/
- TypeScript utility types (`Parameters`, `ReturnType`): https://www.typescriptlang.org/docs/handbook/utility-types.html
- typescript-eslint: https://typescript-eslint.io/rules/no-unsafe-function-type/ · https://typescript-eslint.io/rules/no-unsafe-call/ · https://typescript-eslint.io/rules/no-unsafe-argument/ · https://typescript-eslint.io/rules/no-explicit-any/
- Ruff ANN401: https://docs.astral.sh/ruff/rules/any-type/
- Pyright configuration: https://raw.githubusercontent.com/microsoft/pyright/main/docs/configuration.md
- mypy command line: https://mypy.readthedocs.io/en/stable/command_line.html

## Changelog
- 2026-10-30: draft 1.
- 2026-10-30: docs checks resolved.
- 2026-10-30: tool checks resolved (pyright 1.1.414, tsc 7.0.2, eslint 9.39.5, ruff 0.17.3, mypy 2.4.1)

## Related

- [AR-08: How do I write dispatch that type checkers can follow?](https://beta.llmgraph.in/agent-readable/ar-08.md)
- [AR-09: How should plugin registries be written for coding agents?](https://beta.llmgraph.in/agent-readable/ar-09.md)
- [AR-11: Why does my agent break calls that go through **kwargs?](https://beta.llmgraph.in/agent-readable/ar-11.md)
- [AR-13: Why is Any a blind spot for coding agents?](https://beta.llmgraph.in/agent-readable/ar-13.md)
- [AR-39: How do I make event emitters traceable for coding agents?](https://beta.llmgraph.in/agent-readable/ar-39.md)

Last verified 2026-10-30
