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.
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:
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);
npx tsx problem.tsanyone 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:
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:
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.& stringdrops number and symbol keys, whichkeyofcould otherwise include.PayloadArgs<P>turns a payload into an argument list: an empty tuple forvoid, a one-element tuple otherwise. Used as a rest parameter, it makesemit("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.PatternandMatchinguse 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:
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;
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");
npx tsx map-facts.ts and of the browser terminal6 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
emitknow 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
emitis 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.
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;
}
}
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;
}
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 }));
npx tsx demo.ts and of the browser terminale-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:
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));
npx tsc --noEmit printsmisuse.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:6The 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:
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" });
npx tsx hole.ts and of the browser terminalreorder 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:
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" } });
npx tsc --noEmit printstogether.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:9Checking 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:
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]>.
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);
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
AbortSignalis 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 secondstock.low. oncefired for the first order only.waitForturned the nextorder.cancelledinto a promise, andcancelledis typed as that event's payload, socancelled.reasonis 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:
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");
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:
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));
npx tsc --noEmit printspatterns-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:6These are real limits of typed wildcards, and you should know them before you design around them:
- A wildcard listener gets a union.
event.payload.emailis 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, butPatterndoes 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}.*`withmoduleName: stringhave the type`${string}.*`, which is not aPattern<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
ShopEventsis 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.
EventOfandMatchingare 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:
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));
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).
emitwaits. The publisher gets the report, so a test canawaitthe 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:
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);
}
npx tsx inbox.ts and of the browser terminalpublished 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:
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;
}
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 }));
npx tsx zudo.ts and of the browser terminale-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:
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,
};
}
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);
npx tsx use-typed.ts and of the browser terminalreorder 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 payload | defineEvent<Name, Payload> per event; on<Event<P>> is a claim unless you add a layer like typedBus |
on returns an unsubscribe function | on returns a subscription object with unsubscribe() and active |
"order.*" and "*", typed to a union | The same patterns at runtime; "order.*" also matches a bare "order" event; the handler's type is again your claim |
EmitReport with delivered, failed, errors | The 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 passed | Handlers receive a deeply frozen copy, so one handler cannot change what the next one sees |
Common mistakes
| Mistake | What happens | Fix |
|---|---|---|
emit(name: keyof E, payload: E[keyof E]) without a generic K | Any payload is accepted for any name | A type parameter K extends EventName<E> per call |
| Passing a union-typed name and a separate payload | The correlated-union hole: mismatched pairs compile | Keep them in one EventOf<E> value |
Letting the listener choose its payload type (on<T>) | A claim nothing checks | Derive the listener type from the name |
Business logic in a "*" listener | A union payload and constant narrowing | Wildcards for logging, metrics and forwarding only |
| Subscribing inside a request handler and never unsubscribing | A listener leak: every request adds a listener | Subscribe at startup, or pass the request's AbortSignal |
| Looping over the live listener set | A listener that unsubscribes itself can make the loop skip the next one | Loop over a copy |
| Trusting payloads from a queue because the map says so | Bad data reaches listeners with a trusted type | One 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.
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);
npx tsx bus.test.ts and of the browser terminalPASS 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.placedowns 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, notplace.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
EventEmitterand@zudojs/eventsdo, 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.
HINT 1
Build your own promise: new Promise((resolve, reject) => { ... }). Inside it, const off = bus.once(name, (...args) => { clearTimeout(timer); resolve(args[0] as E[K]); });, and a setTimeout that calls off() then reject(new Error(...)).
HINT 2
The timer must be declared before the listener (so the listener's callback can clear it), and the listener before the timeout fires (so the timeout's callback can call off()). Declare let timer and const off = bus.once(...) in that order, then create the timer referencing both.
SOLUTION
import { EventBus } from "./event-bus.js";
import type { EventName } from "./event-types.js";
import type { ShopEvents } from "./shop-events.js";
function waitForWithin<E extends object, K extends EventName<E>>(bus: EventBus<E>, name: K, ms: number): Promise<E[K]> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
off();
reject(new Error(`timed out waiting for ${name}`));
}, ms);
const off = bus.once(name, (...args) => {
clearTimeout(timer);
resolve(args[0] as E[K]);
});
});
}
const shop = new EventBus<ShopEvents>();
setTimeout(() => void shop.emit("stock.low", { sku: "YAM-TUBER", left: 2 }), 5);
const low = await waitForWithin(shop, "stock.low", 100);
console.log("got", low.sku);
await waitForWithin(shop, "order.cancelled", 20).catch((error: unknown) => console.log(String(error)));
npx tsx wait-within.ts and of the browser terminalgot YAM-TUBER Error: timed out waiting for order.cancelled
K is inferred from the name, so low is typed { sku: string; left: number }. Both paths clean up: the timer is cleared when the event arrives, and the listener is removed when the timer wins, so a late event does not resolve an already rejected promise and nothing leaks.
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.
HINT 1
Formatters<E> needs each payload's real type: { readonly [K in EventName<E>]: (payload: E[K]) => string }.
HINT 2
Inside trackAll: bus.onPattern("*", (event) => { const format = formatters[event.name] as (payload: unknown) => string; log(`${event.name}: ${format(event.payload)}`); });.
SOLUTION
import { EventBus } from "./event-bus.js";
import type { EventName } from "./event-types.js";
import type { ShopEvents } from "./shop-events.js";
type Formatters<E> = { readonly [K in EventName<E>]: (payload: E[K]) => string };
function trackAll<E extends object>(bus: EventBus<E>, formatters: Formatters<E>, log: (line: string) => void): void {
bus.onPattern("*", (event) => {
const format = formatters[event.name] as (payload: unknown) => string;
log(`${event.name}: ${format(event.payload)}`);
});
}
const shop = new EventBus<ShopEvents>();
trackAll(
shop,
{
"order.placed": (o) => `₦${o.totalKobo / 100}`,
"order.cancelled": (c) => c.reason,
"stock.low": (s) => `${s.sku} x${s.left}`,
"cart.cleared": () => "-",
},
(line) => console.log(line),
);
await shop.emit("order.placed", { orderId: "ORD-5", email: "[email protected]", totalKobo: 250_000 });
await shop.emit("stock.low", { sku: "OIL-5L", left: 1 });
await shop.emit("cart.cleared");
npx tsx track-all.ts and of the browser terminalorder.placed: ₦2500 stock.low: OIL-5L x1 cart.cleared: -
The mapped type is the compile-time guarantee: a missing key or a formatter reading a field its event does not have is an error. Inside trackAll, event is a union, so formatters[event.name] is a union of functions that cannot be called with a union payload: the correlated-union problem again, solved with one cast that is safe because name and payload came from the same event object.
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.
HINT 1
shop.publish(event) takes exactly one EventOf value and returns a Promise<EmitReport>. forward can be a thin wrapper around it.
HINT 2
const report = await shop.publish(event); return report.delivered;
SOLUTION
import { EventBus } from "./event-bus.js";
import type { EventOf } from "./event-types.js";
import type { ShopEvents } from "./shop-events.js";
const shop = new EventBus<ShopEvents>();
shop.on("stock.low", (s) => console.log(`reorder ${s.sku}: ${s.left} left`));
shop.on("order.cancelled", (c) => console.log(`refund ${c.orderId}`));
async function forward(event: EventOf<ShopEvents, "order.cancelled" | "stock.low">): Promise<number> {
const report = await shop.publish(event);
return report.delivered;
}
console.log(await forward({ name: "stock.low", payload: { sku: "RICE-50KG", left: 3 } }));
console.log(await forward({ name: "order.cancelled", payload: { orderId: "ORD-1042", reason: "customer changed mind" } }));
npx tsx forward.ts and of the browser terminalreorder RICE-50KG: 3 left 1 refund ORD-1042 1
The wrong call, forward({ name: "stock.low", payload: { orderId: …, reason: … } }), is now a compile error, because no member of the union has that combination. The parameter type also documents which events forward handles.
Recap
- Describe every event in one event map and derive names (
keyof E & string), payload arguments (PayloadArgs, empty forvoid), listener types and the{ name, payload }union (EventOf) from it. - Make
emitandongeneric 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.allSettledso one failure cannot stop the rest, and validate events from outside with one validator per name. @zudojs/eventstypes events withdefineEvent, buton<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.