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

Agent Rules / Frameworks

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.

typescript// 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# 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))
typescript// 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# 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

ToolSettingWhat it catchesChecked
typescript-eslintno-explicit-any (in recommended)(order: any) listener parameters2026-10-30
typescript-eslintno-unsafe-argument (in recommended-type-checked, needs type information)passing an any value into a typed emit2026-10-30
typescript-eslintno-unsafe-assignment (in recommended-type-checked, needs type information)assigning an any payload to a typed variable2026-10-30
tsconfigstrict (includes noImplicitAny, strictFunctionTypes)untyped listener parameters and unsound handler assignment2026-10-30
pyrightreportUnknownArgumentType, 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).
  • AR-08 How do I write dispatch that type checkers can follow?
  • AR-13 Why is Any a blind spot for coding agents?
  • AR-14 How should I type callbacks so agents can follow them?

Last verified

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