Agent Rules / Chimeras
Why did my event listener stop firing?
An order service announces a paid order with bus.emit("order:paid", order). A receipt module and a ledger module each listen for the same string and read the payload as any. The event name is a plain string and the payload is untyped, so renaming either one in the emitter leaves the listeners in other files unchanged, and the compiler has nothing to compare them with. Declare the event names and payload types once in an interface, and put a small typed emit and on in front of the emitter.
The code below is from a small example project we wrote for this page. The break is shown by file and line, and we ran the project's own tests and type checks to confirm it.
1. The setup
EventEmitter is a good fit for this. It ships with Node, the emitting module needs no knowledge of who listens, and adding a listener is one line. An order service has three modules around one shared emitter.
// before/src/orders.ts
export function markPaid(order: Order): void {
bus.emit("order.paid", order); // line 14
}// before/src/email.ts
bus.on("order:paid", (o: any) => { // line 10
sentReceipts.push({ to: o.customerEmail, body: receiptBody(o) });
});
// before/src/ledger.ts
bus.on("order:paid", (o: any) => { // line 15
ledgerEntries.push({ orderId: o.id, cents: o.totalCents }); // line 16
});The tests in tests/orders.test.ts call receiptBody and entryFor directly, with a typed Order.
2. Where the call path hides
- The link is a string. The Node events documentation types
eventNameasstring | symbol, and a listener is called whenemitis given an equal name.orders.ts:14andemail.ts:10connect only because both contain the same characters. A search for callers of the listener finds no call, because the call is insideemit. - **The payload is
any.** The documentation lists theemitarguments as<any>. In the listeners,(o: any)atemail.ts:10andledger.ts:15says the same. The TypeScript handbook saysanyis for when you do not want a value to cause type errors, and that with it "usinganydisables all further type checking". Soo.totalCentsatledger.ts:16is not checked againstOrder. - A missing listener is not an error. The documentation says
emitreturnstrueif the event had listeners andfalseotherwise. The'error'event is the exception the documentation describes;order.paidis not one. - The typed code sits beside it.
receiptBody(order: Order)atemail.ts:6andentryFor(order: Order)atledger.ts:11are typed, and the tests call them. The untyped seam is the three lines around them. - The page for the API has no typed events. The events documentation does not describe a way to type event names or payloads.
3. The edit that breaks it, and which call is left wrong
The event naming was tidied. The emitter in orders.ts now says "order.paid":
// before/src/orders.ts (edit applied)
bus.emit("order.paid", order); // line 14 (was: bus.emit("order:paid", order);)The edit is finished in the file it touches, and tsc --noEmit (TypeScript 5.9.3) reports nothing. The calls left wrong are the two listeners, email.ts:10 and ledger.ts:15. They still wait for "order:paid". When markPaid runs, emit finds no listener for "order.paid", returns false, and throws nothing. No receipt is sent and no ledger entry is written. The order is paid and the two modules that react to it never hear about it.
The tests that exist still pass, because they call receiptBody and entryFor and never go through markPaid. The case that would catch the break is tests/orders.test.ts:22, written as a todo with the reason: it calls markPaid and expects the ledger to hold the entry, and the ledger is empty. On Node 24, node --test reports 2 passed and 1 todo on before/ and exits 0; on after/ all 4 tests pass.
A second edit has the opposite shape. Suppose totalCents is renamed to amountCents on Order, and the emit and listener names are untouched. The typed code is flagged by the compiler: email.ts:7 and ledger.ts:12 read a field that no longer exists. ledger.ts:16 is not flagged, because o is any. The listener runs on every paid order and writes cents: undefined. Nothing throws at that line, and the ledger holds entries with no amount.
4. The fix
An OrderEvents interface lists every event name with its payload type. A small TypedBus holds the Node emitter and exposes emit and on whose arguments are checked against that interface. The listeners lose their any, and their parameter takes the payload type from the event name.
--- before/src/bus.ts+++ after/src/bus.ts import { EventEmitter } from "node:events"; -export const bus = new EventEmitter();+import type { Order } from "./types.ts";++export interface OrderEvents {+ "order:paid": Order;+}++class TypedBus<Events> {+ #emitter = new EventEmitter();++ emit<K extends keyof Events & string>(name: K, payload: Events[K]): boolean {+ return this.#emitter.emit(name, payload);+ }++ on<K extends keyof Events & string>(name: K, listener: (payload: Events[K]) => void): this {+ this.#emitter.on(name, listener);+ return this;+ }+}++export const bus = new TypedBus<OrderEvents>();--- before/src/ledger.ts+++ after/src/ledger.ts-bus.on("order:paid", (o: any) => {- ledgerEntries.push({ orderId: o.id, cents: o.totalCents });+bus.on("order:paid", (order) => {+ ledgerEntries.push(entryFor(order)); });email.ts changes the same way. orders.ts emits "order:paid" again, and Order moves to types.ts so that bus.ts does not import from the module that imports it.
5. Why the fix works
- The name is checked.
K extends keyof Events & stringlimits the name to the keys ofOrderEvents. The generics handbook shows the same device,Key extends keyof Type, where a call with a key that is not in the type is an error at the call site. An emit of"order.paid"is flagged atorders.ts:9inafter/, and a listener for a name that is not in the map is flagged where it is written. - The payload is checked.
Events[K]is the payload type for the name that was passed. The listener's parameter isOrder, soorder.amountCentsor a misspelled field in a listener is an error where it is read, and an emit with the wrong object is an error at the emit. Applied toafter/with TypeScript 5.9.3, the event rename giveserror TS2345atorders.ts:9, and the field rename givesTS2339inemail.tsandledger.tsandTS2353at the emit inorders.ts. - A rename is one edit and one compiler pass. Rename the key in
OrderEventsand everyemitandonstill using the old name is listed by the compiler. The TypeScript handbook builds the same shape in its mapped types page, where a union of events is turned into a handler type per event with a key-remappingasclause. Theafter/code uses the simpler indexed form of it. - The behaviour is tested through the emitter.
after/tests/orders.test.tscallsmarkPaidand checks both the ledger and the sent receipts. - Node's emitter is still doing the work. The wrapper adds types and keeps the runtime behaviour documented for
emitandon.
6. Rules that apply
- AR-13: annotate public function parameters and returns, and use
anyonly at a documented boundary. The listener parameter wasanyin the middle of the service, and now it isOrder. - AR-39: declare event names as a typed union or map with typed payloads. Do not emit free strings with
anyarguments.
7. References
Node.js (fetched 2026-10-30):
- Events (
emitter.emitreturnstrueif the event had listeners andfalseotherwise,eventNameisstring | symbol, arguments<any>,emitter.on; no typed-events section): https://nodejs.org/api/events.html
TypeScript (fetched 2026-10-30):
- Everyday types (
anydisables type checking,noImplicitAny): https://www.typescriptlang.org/docs/handbook/2/everyday-types.html - Mapped types (
keyof, key remapping withas, the event-config example): https://www.typescriptlang.org/docs/handbook/2/mapped-types.html - Generics (
Key extends keyof Typeconstraint, error for a key not in the type): https://www.typescriptlang.org/docs/handbook/2/generics.html
Last verified