# How do I make event emitters traceable for coding agents?

> Rule AR-39: Declare event names as a typed union or enum with typed payloads. Don't emit free strings with any arguments.

`emitter.emit("order:refunded", order, reason)` connects to `emitter.on("order:refunded", ...)` through a string that no tool checks. An agent that adds a parameter to the payload updates the emit and misses a listener in another module. Put the event names and payload types in one declaration, and the compiler checks every emit and every listener against it.

## Why it happens
Node's events documentation defines the contract. Event names are `<string> | <symbol>`, and "typically, event names are camel-cased strings but any valid JavaScript property key can be used." `emitter.emit(eventName[, ...args])` "synchronously calls each of the listeners registered for the event named `eventName`, in the order they were registered, passing the supplied arguments to each." The arguments are documented as `<any>`. The page does not describe a way to type event names or payloads, and it contains no typed-event section anywhere on the page, read to the end; the last part covers `EventTarget`, `CustomEvent` and `NodeEventTarget`, where `emit(type, arg)` takes a `<string>` and an `<any>`.

That leaves the link between emit and listener as a string equality, checked at runtime only. Three effects follow:
- **A typo is silent.** `emit("order:refunded")` with no listener returns `false`, which the docs describe as "if the event had listeners, `false` otherwise." Nothing throws. The one exception is the `'error'` event: with no listener, the error is thrown and the process exits.
- **Payloads drift.** Listeners receive whatever the emitter passed. Change the emit and an old listener reads a missing field.
- **Search finds strings, not relations.** Searching for `"order:refunded"` can find literal uses, but a name built from parts (`` `order:${kind}` ``) never matches.

TypeScript can carry the relation. `keyof` "takes an object type and produces a string or numeric literal union of its keys", and a mapped type uses such a union "to iterate through keys to create a type". The TypeScript handbook's own mapped-type example maps each event's `kind` to a handler type, which is the shape a typed emitter needs. In Python the equivalent is an event class per event and a subscription keyed by that class.

## Bad and good
An order service emits refund events that a billing module listens to.

```ts tab
// bad: free strings, any payload
import { EventEmitter } from "node:events";

export const bus = new EventEmitter();

// orders.ts
bus.emit("order:refunded", order, reason);

// billing.ts
bus.on("order:refunded", (order: any, reason: any) => {
  issueCredit(order.id, reason.code);
});
```

Add a `refundedBy` argument to the emit and `billing.ts` still compiles. Rename the string in one file and the listener never fires, with no error from the compiler or from Node.

```python tab
# bad: free strings, untyped payload
handlers: dict[str, list] = {}

def on(name, fn):
    handlers.setdefault(name, []).append(fn)

def emit(name, *args):
    for fn in handlers.get(name, []):
        fn(*args)

emit("order_refunded", order, reason)
on("order_refunded", lambda order, reason: issue_credit(order.id, reason.code))
```

```ts tab
// good: one declaration of names and payloads, and a typed wrapper
import { EventEmitter } from "node:events";

export type BusEvents = {
  "order:placed": [order: Order];
  "order:refunded": [order: Order, reason: Reason];
};

export class TypedBus {
  private inner = new EventEmitter();

  on<K extends keyof BusEvents>(name: K, fn: (...args: BusEvents[K]) => void): void {
    this.inner.on(name, fn as (...args: unknown[]) => void);
  }

  emit<K extends keyof BusEvents>(name: K, ...args: BusEvents[K]): boolean {
    return this.inner.emit(name, ...args);
  }
}

export const bus = new TypedBus();

// orders.ts
bus.emit("order:refunded", order, reason);

// billing.ts
bus.on("order:refunded", (order, reason) => issueCredit(order.id, reason.code));
```

```python tab
# good: an event class per event; subscribe by class
from dataclasses import dataclass
from typing import Callable, TypeVar

E = TypeVar("E")

@dataclass(frozen=True)
class OrderRefunded:
    order: Order
    reason: Reason

class Bus:
    def __init__(self) -> None:
        self._handlers: dict[type, list[Callable[..., None]]] = {}

    def on(self, event_type: type[E], fn: Callable[[E], None]) -> None:
        self._handlers.setdefault(event_type, []).append(fn)

    def emit(self, event: object) -> None:
        for fn in self._handlers.get(type(event), []):
            fn(event)

bus = Bus()
bus.on(OrderRefunded, lambda e: issue_credit(e.order.id, e.reason.code))
bus.emit(OrderRefunded(order, reason))
```

In the TypeScript version a misspelled name is a compile error, an emit with the wrong arguments is a compile error, and the listener's parameters get their types from the declaration. In the Python version, `OrderRefunded` is a class that a search and a rename both follow, and `frozen=True` makes the payload read-only (the dataclasses docs describe `frozen` as emulating read-only instances). Checked with tsc 7.0.2 / pyright 1.1.414: the TypeScript wrapper compiles clean, and misuse gives TS2554 (wrong argument count), TS2345 (unknown event name) and TS2339 (unknown payload property). The Python version is clean, and pyright reports reportAttributeAccessIssue for assigning to the frozen field and for reading an unknown attribute.

## Trade-offs and when free strings are acceptable
- **A wrapper adds a layer.** The cast inside `on` is the one place the types are asserted, so keep it in the wrapper only.
- **Wide system buses** (a framework event stream, DOM events) have names you don't own. Use their typed definitions where they exist, and wrap the rest in one module.
- **Symbols as names:** Node accepts them, and TypeScript's `unique symbol` gives each its own type, but a string literal union reads better in logs.
- **Python event classes** cost a class per event. Subscribing by class also lets a handler receive a subclass if you choose to match on `isinstance`, a design decision to make once.
- **Typed emitters in the type definitions for Node** may offer a generic parameter on `EventEmitter`. This page doesn't rely on it. In `@types/node` 26.6.6 the generic exists, and a wrongly typed emit argument gives TS2345 (checked with tsc 7.0.2).

## Enforce it
| Tool | Setting | What it catches | Checked |
|---|---|---|---|
| typescript-eslint | `no-explicit-any` (in `recommended`) | `(order: any)` listener parameters | 2026-10-30 |
| typescript-eslint | `no-unsafe-argument` (in `recommended-type-checked`, needs type information) | passing an `any` value into a typed `emit` | 2026-10-30 |
| typescript-eslint | `no-unsafe-assignment` (in `recommended-type-checked`, needs type information) | assigning an `any` payload to a typed variable | 2026-10-30 |
| tsconfig | `strict` (includes `noImplicitAny`, `strictFunctionTypes`) | untyped listener parameters and unsound handler assignment | 2026-10-30 |
| pyright | `reportUnknownArgumentType`, `reportMissingParameterType` (strict mode; see AR-17) | untyped `emit(name, *args)` helpers. Per the pyright configuration page, both rules are `error` in strict mode and `none` in basic and standard. | 2026-10-30 |

No rule in the pages checked flags a string literal passed to `emit`. The typed wrapper is the control, and the lint rules keep `any` from leaking back in.

## Rule block for AGENTS.md / CLAUDE.md
```markdown
## Events
- All events go through the typed bus. Event names and payload types are declared once in `BusEvents` (or as one class per event in Python).
- Never call the raw `EventEmitter` `emit`/`on` with a string literal outside the bus module.
- Never type an event listener parameter as `any`.
- When you add an argument to an event, update `BusEvents`, then fix every compile error at emit and listener sites, and search for the event name to check none were missed.
```

## Edge cases
- **`'error'` events:** per the Node docs, an `'error'` emit with no listener throws and exits the process. Keep an `'error'` entry in the declaration and register a listener.
- **Listeners run synchronously in registration order,** and returned values are ignored. A typed payload doesn't change that, so ordering bugs stay visible only in the code that registers them.
- **Async listeners:** the docs describe `captureRejections` for routing rejected promises to `'error'`. A typed `fn` returning `Promise<void>` still compiles against `=> void`, so decide that rule once.
- **`events.once(emitter, name)`** resolves with an array of the emitted arguments. It takes a string, so wrap it in the bus as well.
- **`any` payloads across modules:** this is the case in CH-08. See also AR-13 for `any` at module boundaries.

## References
- Node.js, events: https://nodejs.org/api/events.html
- TypeScript handbook, mapped types: https://www.typescriptlang.org/docs/handbook/2/mapped-types.html
- TypeScript handbook, `keyof`: https://www.typescriptlang.org/docs/handbook/2/keyof-types.html
- TypeScript handbook, symbols (`unique symbol`): https://www.typescriptlang.org/docs/handbook/symbols.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-argument/ · https://typescript-eslint.io/rules/no-unsafe-assignment/
- Python, `dataclasses`: https://docs.python.org/3/library/dataclasses.html
- Python, `typing` (`Callable`, generics): https://docs.python.org/3/library/typing.html

## Changelog
- 2026-10-30: draft 1.
- 2026-10-30: docs checks resolved (rules-of-hooks, Express error handling, Node events, pyright configuration).
- 2026-10-30: tool checks resolved (eslint 9.39.5, tsc 7.0.2, pyright 1.1.414).

## Related

- [AR-08: How do I write dispatch that type checkers can follow?](https://beta.llmgraph.in/agent-readable/ar-08.md)
- [AR-13: Why is Any a blind spot for coding agents?](https://beta.llmgraph.in/agent-readable/ar-13.md)
- [AR-14: How should I type callbacks so agents can follow them?](https://beta.llmgraph.in/agent-readable/ar-14.md)

Last verified 2026-10-30
