Section 4 · Patterns for Real Codebases
Type-Safe Event Emitters and Builders
21 min3 min readCode playground
Lesson at a glance
Build an event emitter whose event names and payloads are checked from one event map, and a builder whose result type grows with each method call.
No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.
Cartwheel’s checkout emits events: order.shipped, payment.failed, checkout.opened. Analytics, the email service and the order page all listen. The first version used a plain emitter typed with string names and any payloads, and it produced the two classic bugs: a listener for 'order.shiped' that never fired, and a handler reading payload.trackingNo on an event that sends trackingNumber. Both were invisible until someone noticed missing emails.
Everything from this section and the last comes together to fix it.
One map, every type
Describe every event once, in a map from name to payload type:
type CheckoutEvents = {
'checkout.opened': undefined;
'order.shipped': { orderId: number; carrier: string; trackingNumber: string };
'payment.failed': { orderId: number; reason: string };
};You could also generate part of this map with the template-literal mapped type from Template Literal Types. Either way, the map is the single source of truth: names are its keys, payloads are its values.
The typed emitter
type EventArgs<P> = [P] extends [undefined] ? [] : [payload: P];
type Handler = (payload: never) => void;
class Emitter<Events extends Record<string, unknown>> {
#handlers = new Map<keyof Events, Set<Handler>>();
on<K extends keyof Events>(event: K, handler: (payload: Events[K]) => void): () => void {
let set = this.#handlers.get(event);
if (!set) {
set = new Set();
this.#handlers.set(event, set);
}
set.add(handler);
return () => set.delete(handler);
}
emit<K extends keyof Events>(event: K, ...args: EventArgs<Events[K]>): void {
for (const handler of this.#handlers.get(event) ?? []) {
// Handlers for K were registered with a matching payload type in on().
(handler as (payload: unknown) => void)(args[0]);
}
}
}The pattern is the API client’s, applied to events. K is inferred from the event name and has one job: look up the payload in Events[K]. In on, that type flows into the handler’s parameter, so the callback is contextually typed. In emit, it checks the payload you pass.
const bus = new Emitter<CheckoutEvents>();
const off = bus.on('order.shipped', (e) => sendEmail(`Tracking: ${e.trackingNumber}`));
bus.emit('order.shipped', { orderId: 1042, carrier: 'DHL', trackingNumber: '1Z-999' });
bus.emit('checkout.opened'); // no payload needed
bus.on('order.shiped', () => {});
// Error: Argument of type '"order.shiped"' is not assignable to parameter of type 'keyof CheckoutEvents'.
bus.emit('payment.failed', { orderId: 1042 });
// Error: … Property 'reason' is missing …
off(); // unsubscribeEventArgs is the conditional rest-parameter trick from Conditional Types, Distribution and infer: payload-less events take zero extra arguments, the rest take exactly one. The brackets in [P] extends [undefined] stop distribution, so a payload type like string | undefined is still required rather than split.
Notice where the loose types live. Inside the class, handlers are stored as Set<Handler> and called through one commented cast, because a Map cannot express “the value type depends on the key”. Outside, every method is precise. That split, untyped storage behind a typed surface, is how most well-typed libraries are built. The private #handlers field is a real JavaScript private field, so nothing outside can reach in and break the invariant.
Once the core is typed, new methods inherit the precision almost for free. A once that resolves a promise with the next payload is four lines, and its result is typed per event with no extra annotations:
once<K extends keyof Events>(event: K): Promise<Events[K]> {
return new Promise((resolve) => {
const off = this.on(event, (payload) => { off(); resolve(payload); });
});
}
const shipped = await bus.once('order.shipped'); // { orderId; carrier; trackingNumber }This is the payoff of putting the types in one map and threading K through: each method you add is checked against the same source of truth, and none of them repeats a payload type.
Builders whose type grows
A builder chains method calls to assemble something step by step. The type-level trick is to carry what has been built so far in a type parameter, and return a new, wider type from each call. Here is a small query builder for Cartwheel’s order history:
type Order = { id: number; status: OrderStatus; totalCents: number; customer: string };
class OrderQuery<Selected extends keyof Order = never> {
readonly #fields: readonly (keyof Order)[];
constructor(fields: readonly (keyof Order)[] = []) {
this.#fields = fields;
}
select<K extends keyof Order>(...fields: K[]): OrderQuery<Selected | K> {
return new OrderQuery<Selected | K>([...this.#fields, ...fields]);
}
run(rows: Order[]): Pick<Order, Selected>[] {
// #fields holds exactly the keys in Selected.
return rows.map((row) => Object.fromEntries(this.#fields.map((f) => [f, row[f]])) as Pick<Order, Selected>);
}
}
const rows = new OrderQuery().select('id').select('totalCents').run(allOrders);
rows[0].totalCents; // number
rows[0].customer; // Error: Property 'customer' does not exist on type 'Pick<Order, "id" | "totalCents">'.Selected starts as never (nothing selected) and each select returns OrderQuery<Selected | K>. A method cannot change the type of the object it is called on, so each step returns a new instance. That is also good runtime design: builders that never mutate can be shared and reused safely. This is the same technique query builders such as Kysely and Drizzle use at much larger scale.
In the exercise you will build Cartwheel’s typed event bus and a small selecting builder. That completes the patterns section; section 5 turns to shipping all of this, starting with the compiler flags that make these types trustworthy.
Key takeaways
- An event map type (
{ 'order.shipped': { orderId: number } }) is the single source of truth for both event names and payload types. on<K extends keyof Events>(event: K, handler: (payload: Events[K]) => void)ties the handler’s parameter to the event name the caller passes.- A conditional rest parameter lets events with no payload be emitted without a dummy argument.
- A builder can carry state in a type parameter, returning a new, wider type from each call, so the final result type reflects every step.
- Keep the untyped storage inside the class and the precise types on its public methods, so the one cast lives in one place.