EduFlow

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.

  1. 1One map, every type
  2. 2The typed emitter
  3. 3Builders whose type grows

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:

TypeScript
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

TypeScript
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.

TypeScript
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(); // unsubscribe

EventArgs 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:

TypeScript
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:

TypeScript
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.

Further reading

Check your understanding

4 questions

Question 1 of 4

With bus = new Emitter<CheckoutEvents>(), what is the type of p?

TypeScript
type CheckoutEvents = {
  'order.shipped': { orderId: number; carrier: string };
  'payment.failed': { orderId: number; reason: string };
};
bus.on('payment.failed', (p) => console.log(p.reason));
Question 2 of 4

Why type emit with ...args: EventArgs<Events[K]> instead of payload: Events[K]?

Question 3 of 4

What is the type of rows?

TypeScript
const rows = new OrderQuery().select('id').select('totalCents').run(allOrders);
Question 4 of 4

Why does each select call return a new OrderQuery instead of mutating this and returning it?

Practice

Code playground

Type the event bus and the query builder

Make Cartwheel’s two utilities fully typed without changing their runtime behaviour:

  1. Emitter<Events>: on(event, handler) must only accept event names from Events and give the handler that event’s payload type; emit(event, payload) must check the payload, and events whose payload is undefined must be emitted with no payload argument.
  2. OrderQuery: each select(...fields) call adds to the selected fields, and run(rows) returns Pick<Order, …>[] of exactly the selected fields.

Keep any cast inside the classes, in one commented place each.

type OrderStatus = 'processing' | 'shipped' | 'delivered' | 'cancelled' | 'returned';
type Order = { id: number; status: OrderStatus; totalCents: number; customer: string };

const ORDERS: Order[] = [
  { id: 1042, status: 'shipped', totalCents: 18900, customer: 'Mona' },
  { id: 1043, status: 'processing', totalCents: 4500, customer: 'Karim' },
];

class Emitter<Events> {
  #handlers = new Map<string, Set<(payload: any) => void>>();

  on(event: string, handler: (payload: any) => 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(event: string, payload?: any): void {
    for (const handler of this.#handlers.get(event) ?? []) handler(payload);
  }
}

class OrderQuery {
  readonly #fields: readonly (keyof Order)[];

  constructor(fields: readonly (keyof Order)[] = []) {
    this.#fields = fields;
  }

  select(...fields: (keyof Order)[]): OrderQuery {
    return new OrderQuery([...this.#fields, ...fields]);
  }

  run(rows: Order[]): Partial<Order>[] {
    return rows.map((row) => Object.fromEntries(this.#fields.map((f) => [f, row[f]])));
  }
}

// ---- Type tests (do not edit) ----
type CheckoutEvents = {
  'checkout.opened': undefined;
  'order.shipped': { orderId: number; carrier: string };
  'payment.failed': { orderId: number; reason: string };
};

function typeTests(bus: Emitter<CheckoutEvents>) {
  bus.on('payment.failed', (e) => {
    type T1 = Expect<Equal<typeof e, { orderId: number; reason: string }>>;
  });
  bus.emit('checkout.opened');
  bus.emit('order.shipped', { orderId: 1042, carrier: 'DHL' });
  // @ts-expect-error: 'order.shiped' is a typo
  bus.on('order.shiped', () => {});
  // @ts-expect-error: payment.failed needs a reason
  bus.emit('payment.failed', { orderId: 1042 });
  // @ts-expect-error: order.shipped needs a payload
  bus.emit('order.shipped');
}

const rows = new OrderQuery().select('id', 'status').run(ORDERS);
type T2 = Expect<Equal<typeof rows, Pick<Order, 'id' | 'status'>[]>>;
const more = new OrderQuery().select('id').select('totalCents').run(ORDERS);
type T3 = Expect<Equal<typeof more, Pick<Order, 'id' | 'totalCents'>[]>>;

console.log(rows, more);
Loading the editor…

Ctrl/⌘ + Enter to run · Runs in a sandbox in your browser. Nothing leaves your device.

Console

(no output)

Checks

  • The file type-checks with zero errors, including all type tests
  • emit calls only the handlers for that event, with the payload
  • The function returned by on removes the handler
  • run returns only the selected fields, across several select calls