# Why did my event listener stop firing?

> Chimera CH-08 (Node EventEmitter, TypeScript): 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.

```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

## Rules it tests

- [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
