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

Agent Rules / Chimeras

Why did my event listener stop firing?

Chimera CH-08 · Node EventEmitter, TypeScript

Rules it tests: AR-13, AR-39

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.

ts// before/src/orders.ts export function markPaid(order: Order): void { bus.emit("order.paid", order); // line 14 }
ts// 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 eventName as string | symbol, and a listener is called when emit is given an equal name. orders.ts:14 and email.ts:10 connect only because both contain the same characters. A search for callers of the listener finds no call, because the call is inside emit.
  • **The payload is any.** The documentation lists the emit arguments as <any>. In the listeners, (o: any) at email.ts:10 and ledger.ts:15 says the same. The TypeScript handbook says any is for when you do not want a value to cause type errors, and that with it "using any disables all further type checking". So o.totalCents at ledger.ts:16 is not checked against Order.
  • A missing listener is not an error. The documentation says emit returns true if the event had listeners and false otherwise. The 'error' event is the exception the documentation describes; order.paid is not one.
  • The typed code sits beside it. receiptBody(order: Order) at email.ts:6 and entryFor(order: Order) at ledger.ts:11 are 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":

ts// 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.

diff--- 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>();
diff--- 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 & string limits the name to the keys of OrderEvents. 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 at orders.ts:9 in after/, 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 is Order, so order.amountCents or 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 to after/ with TypeScript 5.9.3, the event rename gives error TS2345 at orders.ts:9, and the field rename gives TS2339 in email.ts and ledger.ts and TS2353 at the emit in orders.ts.
  • A rename is one edit and one compiler pass. Rename the key in OrderEvents and every emit and on still 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-remapping as clause. The after/ code uses the simpler indexed form of it.
  • The behaviour is tested through the emitter. after/tests/orders.test.ts calls markPaid and 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 emit and on.

6. Rules that apply

  • AR-13: annotate public function parameters and returns, and use any only at a documented boundary. The listener parameter was any in the middle of the service, and now it is Order.
  • AR-39: declare event names as a typed union or map with typed payloads. Do not emit free strings with any arguments.

7. References

Node.js (fetched 2026-10-30):

  • Events (emitter.emit returns true if the event had listeners and false otherwise, eventName is string | symbol, arguments <any>, emitter.on; no typed-events section): https://nodejs.org/api/events.html

TypeScript (fetched 2026-10-30):

  • Everyday types (any disables type checking, noImplicitAny): https://www.typescriptlang.org/docs/handbook/2/everyday-types.html
  • Mapped types (keyof, key remapping with as, the event-config example): https://www.typescriptlang.org/docs/handbook/2/mapped-types.html
  • Generics (Key extends keyof Type constraint, error for a key not in the type): https://www.typescriptlang.org/docs/handbook/2/generics.html

Last verified

For agentsthis page as Markdown/agent-readable/ch-08.mdllms.txt