LEVEL 6 · LESSON 13 OF 22

Type-safe infrastructure Advanced

A type-safe event system

Build an event bus where emit("order.placed", payload) only accepts the right payload: typed listeners, unsubscribe, wildcards and their limits.

  • 55 min to read and try
  • You need: Generic API design, Mapped types, Conditional types and Template literal types
  • You build: A typed event bus for a shop, with checked names and payloads, unsubscribe by function or AbortSignal, once and waitFor, namespace wildcards, async listeners with error isolation, and a typed layer over @zudojs/events
Test yourself

BY THE END OF THIS LESSON YOU CAN

  • Describe every event of a system in one event map and derive names, payloads and listener types from it
  • Write emit and on so that a wrong event name or payload is a compile error, including events without a payload
  • Explain the correlated-union hole and close it by keeping name and payload in one value
  • Type namespace wildcards with template literal types and say what they cannot express
  • Isolate listener failures and validate events that arrive from outside the program
  • Put a typed layer over @zudojs/events and know which of its types are claims rather than checks

The order e-mail that silently stopped

A shop sends a confirmation e-mail after every order. The checkout code does not call the e-mail code directly; it emits an event, a named message that says "this happened", and whoever cares listens for that name. Checkout does not know that e-mail, inventory or analytics exist, and they do not know about each other. That loose coupling is the whole point of events.

Last month a developer renamed the event from "order.created" to "order.placed", because an order is placed before it is paid. They even used Node's typed EventEmitter, which accepts an event map: a type that lists each event name with its arguments. They updated checkout. They did not find the listener in another folder:

problem.tsNode.js only
import { EventEmitter } from "node:events";

interface OrderPlaced {
  orderId: string;
  email: string;
  totalKobo: number;
}

interface ShopEvents {
  "order.placed": [order: OrderPlaced];
  "stock.low": [sku: string, left: number];
}

const shop = new EventEmitter<ShopEvents>();

// notifications.ts, written last year when the event was still "order.created"
shop.on("order.created", (order) => {
  console.log(`e-mail ${order.email}: we received order ${order.orderId}`);
});

// checkout.ts, after the rename
const delivered = shop.emit("order.placed", { orderId: "ORD-1042", email: "[email protected]", totalKobo: 1_850_000 });
console.log("anyone listening?", delivered);
Output of npx tsx problem.ts
anyone listening? false

This compiles with no error, runs with no error, and no customer gets an e-mail. emit returns false ("nobody listened"), which nobody checks. Node's type definitions deliberately accept any event name, because a Node emitter may also carry events the map does not list, and for an unknown name the listener's parameter is any, so even order.emial would compile. The map made the known events nicer to use, but it did not make unknown names wrong.

In this lesson you build an event system where that bug cannot compile: a misspelled name, a missing field and a listener that expects the wrong shape are all type errors. You will see exactly where TypeScript's help ends (wildcards, variables that hold "one of several" names, data from outside the program), and then how @zudojs/events, which you meet in Events in ZudoJS, types the same ideas. Generic API design built a first TypedEmitter with on and emit; this lesson starts from that idea and goes much deeper.

The event map: one type as the contract

Everything starts from one interface. Each key is an event name, and its value is the payload, the data that travels with the event. An event with nothing to say, like "the cart was cleared", has the payload type void:

shop-events.ts
export interface OrderPlaced {
  orderId: string;
  email: string;
  totalKobo: number;
}

export interface ShopEvents {
  "order.placed": OrderPlaced;
  "order.cancelled": { orderId: string; reason: string };
  "stock.low": { sku: string; left: number };
  "cart.cleared": void;
}

Using one payload value per event (instead of Node's argument lists) keeps things simple: a listener always receives one argument, and you can add a field later without changing every listener's parameter list.

From this map you derive every other type the bus needs, with the tools from Type operators, Mapped types and Conditional types. Nobody writes an event name or a payload type twice:

event-types.ts
export type EventName<E> = keyof E & string;
export type PayloadArgs<P> = [P] extends [void] ? [] : [payload: P];
export type Listener<P> = (...args: PayloadArgs<P>) => void | Promise<void>;

export type EventOf<E, K extends EventName<E> = EventName<E>> = {
  [N in K]: { readonly name: N; readonly payload: E[N] };
}[K];

type Namespace<E> = EventName<E> extends infer N ? (N extends `${infer NS}.${string}` ? NS : never) : never;
export type Pattern<E> = "*" | `${Namespace<E>}.*`;
export type Matching<E, P extends string> = P extends `${infer NS}.*` ? Extract<EventName<E>, `${NS}.${string}`> : EventName<E>;

export interface EmitReport {
  readonly delivered: number;
  readonly failed: number;
  readonly errors: readonly unknown[];
}
  • EventName<E> is the union of the names. & string drops number and symbol keys, which keyof could otherwise include.
  • PayloadArgs<P> turns a payload into an argument list: an empty tuple for void, a one-element tuple otherwise. Used as a rest parameter, it makes emit("cart.cleared") take no payload and every other event require one. The [P] extends [void] wrapping stops the conditional type from distributing over unions, as Conditional types explained.
  • EventOf<E> is a discriminated union of { name, payload } objects, one member per event. It is built with the "mapped type, then index it with its own keys" trick. You will need it twice: for wildcard listeners and to close a type hole.
  • Pattern and Matching use template literal types to describe wildcards like "order.*". The wildcards section explains them.

Check the derived types before building on them. The type-level test helpers are the ones from Type operators:

type-tests.ts
export type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2 ? true : false;
export type Expect<T extends true> = T;
map-facts.ts
import type { Equal, Expect } from "./type-tests.js";
import type { EventName, EventOf, Matching, PayloadArgs, Pattern } from "./event-types.js";
import type { ShopEvents } from "./shop-events.js";

type M1 = Expect<Equal<EventName<ShopEvents>, "order.placed" | "order.cancelled" | "stock.low" | "cart.cleared">>;
type M2 = Expect<Equal<PayloadArgs<ShopEvents["cart.cleared"]>, []>>;
type M3 = Expect<Equal<PayloadArgs<ShopEvents["stock.low"]>, [payload: { sku: string; left: number }]>>;
type M4 = Expect<Equal<Pattern<ShopEvents>, "*" | "order.*" | "stock.*" | "cart.*">>;
type M5 = Expect<Equal<Matching<ShopEvents, "order.*">, "order.placed" | "order.cancelled">>;
type M6 = Expect<Equal<EventOf<ShopEvents, "stock.low">, { readonly name: "stock.low"; readonly payload: { sku: string; left: number } }>>;

console.log("6 facts about the event map compiled");
Output of npx tsx map-facts.ts and of the browser terminal
6 facts about the event map compiled

A typed emit and on

REASON IT OUT

Before you write emit

You are about to write emit(name, payload) and on(name, listener). Think first:

  • Who should decide the payload type: the caller, the listener, or the event name?
  • How does emit know that "cart.cleared" takes no payload?
  • Listeners for different events have different parameter types. What type can the one internal collection that stores all of them have?
  • A listener may unsubscribe itself while emit is looping over the listeners. What happens to the loop?
Show the reasoning

The name must decide, so emit is generic in the name: K extends EventName<E> is inferred from the string you pass, and the payload parameter is E[K]. If the caller or the listener could choose the type, it would be a claim, not a check. The payload parameter becomes a rest parameter of type PayloadArgs<E[K]>, which is [] for void. The internal collection cannot say "for each name, listeners of that name's payload": TypeScript has no type for a Map whose value type depends on the key. So the storage is typed loosely, and exactly one cast connects the public, precise signatures to it. The loop should run over a copy of the listener list, so removing a listener during the loop cannot skip the next one.

emitter.ts
type EventName<E> = keyof E & string;
type PayloadArgs<P> = [P] extends [void] ? [] : [payload: P];
type Listener<P> = (...args: PayloadArgs<P>) => void;

export class TypedEmitter<E extends object> {
  readonly #listeners = new Map<string, Set<Listener<unknown>>>();

  on<K extends EventName<E>>(name: K, listener: Listener<E[K]>): () => void {
    let set = this.#listeners.get(name);
    if (!set) this.#listeners.set(name, (set = new Set()));
    const stored = listener as Listener<unknown>;
    set.add(stored);
    return () => void set.delete(stored);
  }

  emit<K extends EventName<E>>(name: K, ...args: PayloadArgs<E[K]>): number {
    const set = this.#listeners.get(name);
    if (!set) return 0;
    for (const listener of [...set]) listener(...(args as PayloadArgs<unknown>));
    return set.size;
  }
}
shop-events.ts
export interface OrderPlaced {
  orderId: string;
  email: string;
  totalKobo: number;
}

export interface ShopEvents {
  "order.placed": OrderPlaced;
  "order.cancelled": { orderId: string; reason: string };
  "stock.low": { sku: string; left: number };
  "cart.cleared": void;
}
demo.ts
import { TypedEmitter } from "./emitter.js";
import type { ShopEvents } from "./shop-events.js";

const shop = new TypedEmitter<ShopEvents>();

const stop = shop.on("order.placed", (order) => {
  console.log(`e-mail ${order.email}: order ${order.orderId}, ₦${order.totalKobo / 100}`);
});
shop.on("cart.cleared", () => console.log("cart badge -> 0"));

console.log(shop.emit("order.placed", { orderId: "ORD-1042", email: "[email protected]", totalKobo: 1_850_000 }));
console.log(shop.emit("cart.cleared"));
stop();
console.log(shop.emit("order.placed", { orderId: "ORD-1043", email: "[email protected]", totalKobo: 90_000 }));
Output of npx tsx demo.ts and of the browser terminal
e-mail [email protected]: order ORD-1042, ₦18500
1
cart badge -> 0
1
0

You never wrote a type argument: K is inferred as "order.placed" from the string, and from it the payload type flows into the listener's order parameter (contextual typing, from Type inference). on returns an unsubscribe function: calling it removes exactly that listener, so the caller never needs access to the emitter's internals. Now every mistake from the opening story, and a few more:

misuse.ts
import { TypedEmitter } from "./emitter.js";
import type { ShopEvents } from "./shop-events.js";

const shop = new TypedEmitter<ShopEvents>();

shop.on("order.created", (order) => console.log(order));
shop.emit("order.placed", { orderId: "ORD-1042", email: "[email protected]", totalKobo: "18500" });
shop.emit("stock.low");
shop.emit("cart.cleared", { items: 3 });
shop.on("stock.low", (event) => console.log(event.quantity));
What npx tsc --noEmit prints
misuse.ts:6:9 - error TS2345: Argument of type '"order.created"' is not assignable to parameter of type 'EventName<ShopEvents>'.

6 shop.on("order.created", (order) => console.log(order));
          ~~~~~~~~~~~~~~~

misuse.ts:7:72 - error TS2322: Type 'string' is not assignable to type 'number'.

7 shop.emit("order.placed", { orderId: "ORD-1042", email: "[email protected]", totalKobo: "18500" });
                                                                         ~~~~~~~~~

  shop-events.ts:4:3 - The expected type comes from property 'totalKobo' which is declared here on type 'OrderPlaced'
    4   totalKobo: number;
        ~~~~~~~~~

misuse.ts:8:6 - error TS2554: Expected 2 arguments, but got 1.

8 shop.emit("stock.low");
       ~~~~

  emitter.ts:16:41 - Arguments for the rest parameter 'args' were not provided.
    16   emit<K extends EventName<E>>(name: K, ...args: PayloadArgs<E[K]>): number {
                                               ~~~~~~~~~~~~~~~~~~~~~~~~~~

misuse.ts:9:27 - error TS2554: Expected 1 arguments, but got 2.

9 shop.emit("cart.cleared", { items: 3 });
                            ~~~~~~~~~~~~

misuse.ts:10:51 - error TS2339: Property 'quantity' does not exist on type '{ sku: string; left: number; }'.

10 shop.on("stock.low", (event) => console.log(event.quantity));
                                                     ~~~~~~~~


Found 5 errors in the same file, starting at: misuse.ts:6

The old name is refused, the price as text is refused, a missing payload and an unexpected payload are refused, and the listener cannot read a field the event does not have. The single as Listener<unknown> cast inside on is the price of the heterogeneous storage. It is safe for one reason only: a listener stored under name K is only ever called with arguments that were checked against E[K] by emit. That reasoning lives in your head, not in the compiler, which is why such a cast belongs in one small, tested class and nowhere else.

The correlated-union hole

The generic K is precise as long as you pass a literal name. Now a function forwards "one of two" events, so its name and payload are unions:

hole.ts
import { TypedEmitter } from "./emitter.js";
import type { ShopEvents } from "./shop-events.js";

const shop = new TypedEmitter<ShopEvents>();
shop.on("stock.low", (event) => console.log(`reorder ${event.sku}: ${event.left} left`));

function forward(name: "order.cancelled" | "stock.low", payload: ShopEvents["order.cancelled" | "stock.low"]) {
  shop.emit(name, payload);
}

forward("stock.low", { orderId: "ORD-1042", reason: "customer changed mind" });
Output of npx tsx hole.ts and of the browser terminal
reorder undefined: undefined left

It compiles and it is wrong. Inside forward, K is inferred as the whole union "order.cancelled" | "stock.low", so E[K] is the union of both payloads, and a cancellation payload is accepted for "stock.low". Two separate union variables have lost the information that they belong together. TypeScript calls this the correlated union problem, and there is no compiler switch for it. The fix is a design rule: as long as a name and a payload travel together, keep them in one value. That is what EventOf<E> is for. Each member ties one name to its payload, so a mismatched pair cannot even be written:

together.ts
import type { EventOf } from "./event-types.js";
import type { ShopEvents } from "./shop-events.js";

function forward(event: EventOf<ShopEvents, "order.cancelled" | "stock.low">): string {
  return event.name === "stock.low" ? `reorder ${event.payload.sku}` : `cancel ${event.payload.orderId}`;
}

forward({ name: "stock.low", payload: { sku: "RICE-50KG", left: 3 } });
forward({ name: "stock.low", payload: { orderId: "ORD-1042", reason: "customer changed mind" } });
What npx tsc --noEmit prints
together.ts:9:41 - error TS2353: Object literal may only specify known properties, and 'orderId' does not exist in type '{ sku: string; left: number; }'.

9 forward({ name: "stock.low", payload: { orderId: "ORD-1042", reason: "customer changed mind" } });
                                          ~~~~~~~


Found 1 error in together.ts:9

Checking event.name also narrows event.payload, which two separate variables could never do. The full bus below gets a publish(event) method that takes exactly such an object.

The full bus: unsubscribe, once, waitFor and wildcards

A production event system needs more than on and emit. Listeners that live as long as one HTTP request must be removed when the request ends, or they pile up (a listener leak). Some code needs only the next event. Audit logs want every event. And listeners are often async. Here is the complete bus, built on the types from event-types.ts:

event-bus.ts
import type { EmitReport, EventName, EventOf, Listener, Matching, PayloadArgs, Pattern } from "./event-types.js";

type Stored = (...args: unknown[]) => void | Promise<void>;
type PatternListener = (event: { name: string; payload: unknown }) => void | Promise<void>;

export class EventBus<E extends object> {
  readonly #listeners = new Map<string, Set<Stored>>();
  readonly #patterns = new Map<PatternListener, string>();

  on<K extends EventName<E>>(name: K, listener: Listener<E[K]>, options: { signal?: AbortSignal } = {}): () => void {
    let set = this.#listeners.get(name);
    if (!set) this.#listeners.set(name, (set = new Set()));
    const stored = listener as Stored;
    set.add(stored);
    const off = () => void set.delete(stored);
    options.signal?.addEventListener("abort", off, { once: true });
    return off;
  }

  once<K extends EventName<E>>(name: K, listener: Listener<E[K]>): () => void {
    const off = this.on(name, (...args) => {
      off();
      return listener(...args);
    });
    return off;
  }

  waitFor<K extends EventName<E>>(name: K): Promise<E[K]> {
    return new Promise((resolve) => this.once(name, (...args) => resolve(args[0] as E[K])));
  }

  onPattern<P extends Pattern<E>>(pattern: P, listener: (event: EventOf<E, Matching<E, P>>) => void | Promise<void>): () => void {
    const stored = listener as PatternListener;
    this.#patterns.set(stored, pattern);
    return () => void this.#patterns.delete(stored);
  }

  emit<K extends EventName<E>>(name: K, ...args: PayloadArgs<E[K]>): Promise<EmitReport> {
    return this.#dispatch(name, args);
  }

  publish(event: EventOf<E>): Promise<EmitReport> {
    return this.#dispatch(event.name, event.payload === undefined ? [] : [event.payload]);
  }

  async #dispatch(name: string, args: readonly unknown[]): Promise<EmitReport> {
    const calls: (() => void | Promise<void>)[] = [];
    for (const listener of this.#listeners.get(name) ?? []) calls.push(() => listener(...args));
    for (const [listener, pattern] of this.#patterns) {
      if (pattern === "*" || name.startsWith(pattern.slice(0, -1))) calls.push(() => listener({ name, payload: args[0] }));
    }
    const results = await Promise.allSettled(calls.map(async (call) => call()));
    const errors = results.flatMap((r) => (r.status === "rejected" ? [r.reason] : []));
    return { delivered: results.length - errors.length, failed: errors.length, errors };
  }
}

The structure is the important part: a typed facade over an untyped core. The public methods (on, once, emit, publish, onPattern) have precise generic signatures. The private #dispatch works with plain strings and unknown values. Every cast sits on the line where a precise type enters the loose storage. once and waitFor are built on on and need no new cast except args[0] as E[K], because TypeScript cannot index the still-unresolved PayloadArgs<E[K]>.

subscriptions.ts
import { EventBus } from "./event-bus.js";
import type { ShopEvents } from "./shop-events.js";

const shop = new EventBus<ShopEvents>();

const request = new AbortController();
shop.on("stock.low", (s) => console.log(`[request] ${s.sku} is low`), { signal: request.signal });
shop.once("order.placed", (o) => console.log(`[once] first order today: ${o.orderId}`));

const nextCancellation = shop.waitFor("order.cancelled");

await shop.emit("stock.low", { sku: "RICE-50KG", left: 3 });
request.abort();
await shop.emit("stock.low", { sku: "RICE-50KG", left: 2 });

await shop.emit("order.placed", { orderId: "ORD-1", email: "[email protected]", totalKobo: 500_000 });
await shop.emit("order.placed", { orderId: "ORD-2", email: "[email protected]", totalKobo: 90_000 });

await shop.emit("order.cancelled", { orderId: "ORD-2", reason: "paid twice" });
const cancelled = await nextCancellation;
console.log("waitFor got:", cancelled.reason);
Output of npx tsx subscriptions.ts and of the browser terminal
[request] RICE-50KG is low
[once] first order today: ORD-1
waitFor got: paid twice
  • An AbortSignal is the standard way to cancel many things at once: fetch, timers and listeners can all share one signal, so ending a request cleans up everything it started. request.abort() removed the listener before the second stock.low.
  • once fired for the first order only. waitFor turned the next order.cancelled into a promise, and cancelled is typed as that event's payload, so cancelled.reason is checked.

Wildcards and their limits

An audit log wants every event, and an order dashboard wants every order.* event. A wildcard listener cannot receive "the payload", because it receives several kinds of event. So onPattern hands it the { name, payload } union for exactly the matching names, and the listener narrows on name:

patterns.ts
import { EventBus } from "./event-bus.js";
import type { ShopEvents } from "./shop-events.js";

const shop = new EventBus<ShopEvents>();

shop.onPattern("order.*", (event) => {
  switch (event.name) {
    case "order.placed":
      console.log(`[orders] +₦${event.payload.totalKobo / 100}`);
      break;
    case "order.cancelled":
      console.log(`[orders] ${event.payload.orderId} cancelled: ${event.payload.reason}`);
      break;
  }
});
shop.onPattern("*", (event) => console.log(`[audit] ${event.name}`));

await shop.emit("order.placed", { orderId: "ORD-7", email: "[email protected]", totalKobo: 1_200_000 });
await shop.emit("order.cancelled", { orderId: "ORD-7", reason: "out of stock" });
await shop.emit("cart.cleared");
Output of npx tsx patterns.ts and of the browser terminal
[orders] +₦12000
[audit] order.placed
[orders] ORD-7 cancelled: out of stock
[audit] order.cancelled
[audit] cart.cleared

How the types work: Namespace<ShopEvents> takes every name apart with `${infer NS}.${string}` and keeps the part before the first dot, giving "order" | "stock" | "cart". Pattern turns that into "*" | "order.*" | "stock.*" | "cart.*". Matching<E, "order.*"> keeps the names that start with order., and EventOf turns them into the union the listener receives. The type-level rule and the runtime rule (name.startsWith("order.")) must agree, and they do: both match every depth below the namespace. Here is what the compiler refuses:

patterns-misuse.ts
import { EventBus } from "./event-bus.js";
import type { ShopEvents } from "./shop-events.js";

const shop = new EventBus<ShopEvents>();

shop.onPattern("shipping.*", (event) => console.log(event.name));
shop.onPattern("order.*", (event) => console.log(event.payload.email));
shop.onPattern("*.placed", (event) => console.log(event.name));
What npx tsc --noEmit prints
patterns-misuse.ts:6:16 - error TS2345: Argument of type '"shipping.*"' is not assignable to parameter of type 'Pattern<ShopEvents>'.

6 shop.onPattern("shipping.*", (event) => console.log(event.name));
                 ~~~~~~~~~~~~

patterns-misuse.ts:7:64 - error TS2339: Property 'email' does not exist on type 'OrderPlaced | { orderId: string; reason: string; }'.
  Property 'email' does not exist on type '{ orderId: string; reason: string; }'.

7 shop.onPattern("order.*", (event) => console.log(event.payload.email));
                                                                 ~~~~~

patterns-misuse.ts:8:16 - error TS2345: Argument of type '"*.placed"' is not assignable to parameter of type 'Pattern<ShopEvents>'.

8 shop.onPattern("*.placed", (event) => console.log(event.name));
                 ~~~~~~~~~~


Found 3 errors in the same file, starting at: patterns-misuse.ts:6

These are real limits of typed wildcards, and you should know them before you design around them:

  • A wildcard listener gets a union. event.payload.email is refused because a cancellation has no e-mail. That is correct, but it means wildcard listeners are for cross-cutting work (logging, metrics, forwarding to a queue) that treats events generically, not for business logic.
  • Only the patterns you teach the types. "*.placed" ("any namespace, action placed") is a perfectly reasonable runtime pattern, but Pattern does not describe it, so it is refused. Every new pattern form needs new template-literal machinery and matching runtime code, and the two can drift apart. Keep the pattern language small.
  • Patterns built at runtime such as `${moduleName}.*` with moduleName: string have the type `${string}.*`, which is not a Pattern<ShopEvents>. You must check the value at runtime and cast, or not use the typed method.
  • The map is closed at compile time. A plugin loaded at runtime cannot add events the types know about. Because ShopEvents is an interface, a plugin package can add names with module augmentation (Declaration files shows how), but only for plugins known when you compile.
  • Large maps cost compile time. EventOf and Matching are recomputed per use. A few hundred events are fine; thousands of names with deep patterns make editors slow (Compiler performance).

Async listeners and error isolation

An e-mail listener that throws must not stop the inventory listener from reserving stock. The bus runs every listener, collects failures, and reports them to the publisher instead of throwing the first one. This is error isolation. Promise.allSettled does the work: it waits for every promise and never rejects. Wrapping each call in async (call) => call() turns a synchronous throw into a rejected promise too, so both kinds of failure land in the same report:

isolation.ts
import { EventBus } from "./event-bus.js";
import type { ShopEvents } from "./shop-events.js";

const shop = new EventBus<ShopEvents>();

shop.on("order.placed", (o) => console.log(`[inventory] reserve items for ${o.orderId}`));
shop.on("order.placed", async (o) => {
  await new Promise((resolve) => setTimeout(resolve, 10));
  throw new Error(`SMTP timeout sending to ${o.email}`);
});
shop.on("order.placed", () => {
  throw new TypeError("loyalty service misconfigured");
});
shop.on("order.placed", async (o) => {
  await new Promise((resolve) => setTimeout(resolve, 5));
  console.log(`[analytics] revenue +${o.totalKobo}`);
});

const report = await shop.emit("order.placed", { orderId: "ORD-9", email: "[email protected]", totalKobo: 700_000 });
console.log(`delivered ${report.delivered}, failed ${report.failed}`);
for (const error of report.errors) console.log(" -", String(error));
Output of npx tsx isolation.ts and of the browser terminal
[inventory] reserve items for ORD-9
[analytics] revenue +700000
delivered 2, failed 2
 - Error: SMTP timeout sending to [email protected]
 - TypeError: loyalty service misconfigured

Two design decisions are baked in here, and both are choices, not facts:

  • Listeners run concurrently. Their synchronous parts start in subscription order, but the analytics listener finished before the e-mail listener. If one listener must finish before another starts, they are not independent, and they probably belong in one listener or one command (next lesson).
  • emit waits. The publisher gets the report, so a test can await the event and then check its effects. A web request, though, should not wait ten seconds for an e-mail server. In production, slow side effects move to a queue (Queues and background jobs), and the listener only enqueues a job.

The report's errors are unknown[], not Error[]: a listener can throw anything, as Error handling in TypeScript showed. String(error) is safe for any value.

Events from outside the program

Everything so far was checked because the payloads were written in TypeScript. Events that arrive from a message queue, a webhook or another service are just text. JSON.parse returns any, and the event map says nothing about what really arrived. Types never validate data at runtime (Types meet the runtime), so you need one runtime check per event. A mapped type makes the compiler demand that check for every name in the map, so a new event cannot be added without a validator:

inbox.ts
import { EventBus } from "./event-bus.js";
import type { EventName, EventOf } from "./event-types.js";
import type { ShopEvents } from "./shop-events.js";

type Validators<E> = { readonly [K in EventName<E>]: (payload: unknown) => payload is E[K] };

const isObject = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null;

const validators: Validators<ShopEvents> = {
  "order.placed": (p): p is ShopEvents["order.placed"] =>
    isObject(p) && typeof p.orderId === "string" && typeof p.email === "string" && Number.isInteger(p.totalKobo),
  "order.cancelled": (p): p is ShopEvents["order.cancelled"] =>
    isObject(p) && typeof p.orderId === "string" && typeof p.reason === "string",
  "stock.low": (p): p is ShopEvents["stock.low"] => isObject(p) && typeof p.sku === "string" && typeof p.left === "number",
  "cart.cleared": (p): p is void => p === undefined,
};

function parseEvent(raw: string): EventOf<ShopEvents> | string {
  const message: unknown = JSON.parse(raw);
  if (!isObject(message) || typeof message.name !== "string") return "not an event";
  if (!Object.hasOwn(validators, message.name)) return `unknown event "${message.name}"`;
  const name = message.name as EventName<ShopEvents>;
  if (!validators[name](message.payload)) return `bad payload for ${name}`;
  return { name, payload: message.payload } as EventOf<ShopEvents>;
}

const shop = new EventBus<ShopEvents>();
shop.onPattern("*", (event) => console.log("published", event.name));

const queue = [
  '{"name":"stock.low","payload":{"sku":"GARRI-5KG","left":4}}',
  '{"name":"stock.low","payload":{"sku":"GARRI-5KG","left":"four"}}',
  '{"name":"order.shipped","payload":{"orderId":"ORD-3"}}',
  '{"name":"cart.cleared"}',
];
for (const raw of queue) {
  const event = parseEvent(raw);
  if (typeof event === "string") console.log("rejected:", event);
  else await shop.publish(event);
}
Output of npx tsx inbox.ts and of the browser terminal
published stock.low
rejected: bad payload for stock.low
rejected: unknown event "order.shipped"
published cart.cleared

The two casts in parseEvent are each backed by a runtime check on the line before: Object.hasOwn proves the name is in the map (and not an inherited key like "toString"), and the validator proves the payload fits that name. The compiler cannot connect "the validator for name passed" to "the payload fits name", which is the correlated-union problem once more. Writing validators by hand gets long; Runtime validation and Validation in ZudoJS generate them from schemas.

The same ideas in @zudojs/events

@zudojs/events is a full event bus: every event is an object with a unique id, a timestamp and a payload, handlers have priorities and timeouts, middleware can wrap publishing, and failures are reported per handler. Events in ZudoJS teaches it in full. Here, look only at its types. Its runtime event type is a plain string, so the typing happens in two places: defineEvent ties a name to a payload type, and on takes the handler's event type as a type argument:

shop-events.ts
export interface OrderPlaced {
  orderId: string;
  email: string;
  totalKobo: number;
}

export interface ShopEvents {
  "order.placed": OrderPlaced;
  "order.cancelled": { orderId: string; reason: string };
  "stock.low": { sku: string; left: number };
  "cart.cleared": void;
}
zudo.ts
import { createEventBus, defineEvent } from "@zudojs/events";
import type { Event } from "@zudojs/events";
import type { OrderPlaced, ShopEvents } from "./shop-events.js";

const OrderPlacedEvent = defineEvent<"order.placed", OrderPlaced>("order.placed");
const StockLowEvent = defineEvent<"stock.low", ShopEvents["stock.low"]>("stock.low");

const bus = createEventBus();

bus.on<Event<OrderPlaced>>("order.placed", (event) => {
  console.log(`e-mail ${event.payload.email}: order ${event.payload.orderId}`);
});
bus.on<Event<OrderPlaced>>("stock.low", (event) => {
  console.log(`wrong claim: ${event.payload.email}`);
});
bus.on("order.*", (event) => console.log("[orders]", event.type));

const placed = await bus.publish(OrderPlacedEvent.create({ orderId: "ORD-1042", email: "[email protected]", totalKobo: 1_850_000 }));
console.log(placed.handled, placed.handlerCount);
await bus.publish(StockLowEvent.create({ sku: "RICE-50KG", left: 3 }));
Output of npx tsx zudo.ts and of the browser terminal
e-mail [email protected]: order ORD-1042
[orders] order.placed
true 2
wrong claim: undefined

OrderPlacedEvent.create(…) is checked: the payload must be an OrderPlaced. But look at the second handler. bus.on<Event<OrderPlaced>>("stock.low", …) compiles, because nothing connects the type argument to the name. A type argument that only describes what a callback will receive is a claim, exactly like as: Generic API design called these return-only generics. The handler then reads undefined. This is not a bug in the package so much as a trade-off: a bus whose names are plain strings (so that "order.*", modules and plugins work at runtime) cannot know every payload at compile time. The package also exports EventUnion<Map>, the same idea as your EventOf, but its constraint is Record<string, unknown>, which an interface does not satisfy; use a type alias for maps you pass to it.

You can have both: the package's runtime and your compile-time map. A thin layer that takes the map as a type parameter lets the name choose the type again, and hides the claim in one place:

typed-zudo.ts
import { createEventBus } from "@zudojs/events";
import type { Event, EventBus, EventPublishResult } from "@zudojs/events";

export interface TypedZudoBus<M extends object> {
  on<K extends keyof M & string>(type: K, handler: (event: Event<M[K]>) => void | Promise<void>): () => void;
  publish<K extends keyof M & string>(type: K, payload: M[K]): Promise<EventPublishResult<Event<M[K]>>>;
  readonly raw: EventBus;
}

export function typedBus<M extends object>(raw: EventBus = createEventBus()): TypedZudoBus<M> {
  return {
    on(type, handler) {
      const subscription = raw.on<Event<M[typeof type]>>(type, handler);
      return () => subscription.unsubscribe();
    },
    publish(type, payload) {
      return raw.publishEvent({ type, payload });
    },
    raw,
  };
}
use-typed.ts
import { typedBus } from "./typed-zudo.js";
import type { ShopEvents } from "./shop-events.js";

const shop = typedBus<ShopEvents>();
const off = shop.on("stock.low", (event) => console.log(`reorder ${event.payload.sku} (${event.payload.left} left)`));
shop.raw.on("*", (event) => console.log("[audit]", event.type, event.id.startsWith("event:")));

const result = await shop.publish("stock.low", { sku: "RICE-50KG", left: 3 });
console.log("handled:", result.handled, "handlers:", result.handlerCount);
off();
console.log("handlers left:", shop.raw.handlerCount);
Output of npx tsx use-typed.ts and of the browser terminal
reorder RICE-50KG (3 left)
[audit] stock.low true
handled: true handlers: 2
handlers left: 1

Compare the pieces with what you built:

Your bus@zudojs/events
Event map interface, name chooses the payloaddefineEvent<Name, Payload> per event; on<Event<P>> is a claim unless you add a layer like typedBus
on returns an unsubscribe functionon returns a subscription object with unsubscribe() and active
"order.*" and "*", typed to a unionThe same patterns at runtime; "order.*" also matches a bare "order" event; the handler's type is again your claim
EmitReport with delivered, failed, errorsThe publish result has handled, succeeded, failed and errors (each an EventHandlerError whose cause is what was thrown). The bus continues after a failing handler by default
Payload is whatever you passedHandlers receive a deeply frozen copy, so one handler cannot change what the next one sees

Common mistakes

MistakeWhat happensFix
emit(name: keyof E, payload: E[keyof E]) without a generic KAny payload is accepted for any nameA type parameter K extends EventName<E> per call
Passing a union-typed name and a separate payloadThe correlated-union hole: mismatched pairs compileKeep them in one EventOf<E> value
Letting the listener choose its payload type (on<T>)A claim nothing checksDerive the listener type from the name
Business logic in a "*" listenerA union payload and constant narrowingWildcards for logging, metrics and forwarding only
Subscribing inside a request handler and never unsubscribingA listener leak: every request adds a listenerSubscribe at startup, or pass the request's AbortSignal
Looping over the live listener setA listener that unsubscribes itself can make the loop skip the next oneLoop over a copy
Trusting payloads from a queue because the map says soBad data reaches listeners with a trusted typeOne validator per event at the boundary

Testing the event system

An event system needs both kinds of test from Generic API design. Type-level tests prove the compiler refuses what it should: // @ts-expect-error makes tsc fail if the next line ever stops being an error, for example after someone loosens emit to string. Runtime tests prove the behaviour no type can see: that unsubscribing works, that once fires once and that failures are isolated.

bus.test.ts
import { EventBus } from "./event-bus.js";
import type { ShopEvents } from "./shop-events.js";

const shop = new EventBus<ShopEvents>();

// @ts-expect-error: unknown event name
void shop.emit("order.created", { orderId: "ORD-1", email: "[email protected]", totalKobo: 1 });
// @ts-expect-error: payload of another event
void shop.emit("stock.low", { orderId: "ORD-1", reason: "duplicate" });
// @ts-expect-error: "cart.cleared" has no payload
void shop.emit("cart.cleared", {});
// @ts-expect-error: a mismatched pair cannot be published
void shop.publish({ name: "stock.low", payload: { orderId: "ORD-1", reason: "duplicate" } });

function check(label: string, actual: unknown, expected: unknown): void {
  console.log(`${Object.is(actual, expected) ? "PASS" : "FAIL"} ${label}`);
}

const seen: string[] = [];
const off = shop.on("stock.low", (s) => void seen.push(s.sku));
shop.once("stock.low", () => void seen.push("once"));
await shop.emit("stock.low", { sku: "A", left: 1 });
off();
await shop.emit("stock.low", { sku: "B", left: 1 });
check("unsubscribe and once", seen.join(","), "A,once");

shop.on("cart.cleared", () => {
  throw new Error("boom");
});
shop.on("cart.cleared", () => void seen.push("second"));
const report = await shop.emit("cart.cleared");
check("a failing listener does not stop the next", seen.at(-1), "second");
check("the failure is reported", report.failed, 1);
Output of npx tsx bus.test.ts and of the browser terminal
PASS unsubscribe and once
PASS a failing listener does not stop the next
PASS the failure is reported

With Vitest (Testing TypeScript), check becomes expect(…).toBe(…), and expectTypeOf can also assert that the listener's parameter has the expected type.

Typed events in production

  • One map per boundary, owned by the publisher. The module that emits order.placed owns its payload type and exports it. Listeners import the type; they never redeclare it.
  • Payloads are a public contract. Adding an optional field is safe. Renaming or removing a field breaks every listener, including ones in other services that read from a queue and are not compiled with yours. For a breaking change, publish a new event (order.placed.v2) alongside the old one until every listener has moved.
  • In-memory events are not durable. If the process crashes between saving the order and the e-mail listener finishing, the e-mail is lost. Durable side effects go through a database outbox or a queue (Queues in ZudoJS, Event-driven systems).
  • Events describe the past. Name them in the past tense (order.placed, not place.order) and do not expect a return value from listeners. When you need an answer or a guarantee that exactly one handler ran, you want a command or a query, which is the next lesson.
  • Watch the listener count. Count listeners per name in development and warn above a limit, the way Node's EventEmitter and @zudojs/events do, to catch leaks early.

Practice

TRY IT YOURSELF

waitFor with a timeout

waitFor waits forever if the event never comes. Write waitForWithin(bus, name, ms) that resolves with the payload, or rejects with Error("timed out waiting for NAME") after ms milliseconds, and unsubscribes in both cases. The payload type must still follow the name.

Write it in the editor, then press Check. Hints and the solution open up once you have checked your code.

TRY IT YOURSELF

One analytics line per event, enforced

Analytics wants one line of text for every shop event. Write a mapped type Formatters<E> that requires a formatter for every event name, taking that event's payload, and a function trackAll(bus, formatters, log) that subscribes all of them. Adding a new event to ShopEvents must make the formatters object fail to compile until it has a line for it.

Write it in the editor, then press Check. Hints and the solution open up once you have checked your code.

TRY IT YOURSELF

Close the hole in forward

The forward(name, payload) function from the correlated-union section accepted a cancellation payload for "stock.low". Rewrite it so that it takes one EventOf value and publishes it on the full bus, and show that the correct call still works.

Write it in the editor, then press Check. Hints and the solution open up once you have checked your code.

Recap

  • Describe every event in one event map and derive names (keyof E & string), payload arguments (PayloadArgs, empty for void), listener types and the { name, payload } union (EventOf) from it.
  • Make emit and on generic in the name, K extends EventName<E>, so the name decides the payload. A type argument chosen by the caller or the listener is a claim, not a check.
  • Two separate union-typed variables lose their pairing (the correlated-union problem). Keep a name and its payload in one value.
  • Store listeners loosely and keep the single cast where precise types enter the storage: a typed facade over an untyped core.
  • Typed wildcards give listeners a union, only support the pattern forms you teach the types, cannot follow patterns built at runtime and describe a map that is closed at compile time.
  • Run listeners with Promise.allSettled so one failure cannot stop the rest, and validate events from outside with one validator per name.
  • @zudojs/events types events with defineEvent, but on<Event<P>> is a claim; a thin typed layer puts your map back in charge.

Next: Type-safe CQRS, where exactly one handler answers each command and query, and its result type follows the request.

Test yourself

Five questions, picked at random from this lesson's question bank. Some ask you to choose an answer, some to predict what code prints, and some to write code and run it in the terminal. Get 4 of 5 right to pass. If you don't, read the explanations and try again: you get 5 different questions.