๐ŸŽ“ A1Learner
๐Ÿ”
0 of 37 lessons completed

TypeScript Project: Type-Safe Event Library

A type-safe event library ties event names to payload shapes, so every handler and every emit call is checked against one event map. This capstone builds the library from the mapped types, key remapping, and variance ideas of this course.

The event map and its handlers

The event map pairs three event names with their payload shapes. The handler map remaps each name to an on-prefixed handler name, and each handler reads its own payload shape.

interface AppEvents {
  joined: { userName: string };
  left: { userName: string };
  scored: { points: number };
}

type Handler<E, K extends keyof E> = (payload: E[K]) => void;

type HandlerMap<E> = {
  [K in keyof E as `on${Capitalize<K & string>}`]: Handler<E, K>;
};

const handlers: HandlerMap<AppEvents> = {
  onJoined: (payload) => console.log('joined: ' + payload.userName),
  onLeft: (payload) => console.log('left: ' + payload.userName),
  onScored: (payload) => console.log('scored: ' + payload.points),
};

handlers.onJoined({ userName: 'Asha' });
handlers.onScored({ points: 12 });

Payloads read by event name

An indexed access reads one payload shape out of the map by its event name. The scored payload and the joined payload are different types, and each constant accepts only its own shape.

interface AppEvents {
  joined: { userName: string };
  left: { userName: string };
  scored: { points: number };
}

type Payload<E, K extends keyof E> = E[K];

const scoredPayload: Payload<AppEvents, 'scored'> = { points: 30 };
const joinedPayload: Payload<AppEvents, 'joined'> = { userName: 'Ben' };
console.log(scoredPayload.points);
console.log(joinedPayload.userName);

A listener that only consumes payloads

The in marker states that Listener takes payloads in and never returns one. A listener for a broad shape can serve where a narrower listener is expected, because it already handles every value the narrow site sends.

interface Listener<in P> {
  listen: (payload: P) => void;
}

interface Fruit {
  kind: string;
}

interface Apple extends Fruit {
  crisp: boolean;
}

const anyFruit: Listener<Fruit> = {
  listen: (payload) => console.log('fruit: ' + payload.kind),
};

const appleListener: Listener<Apple> = anyFruit;
appleListener.listen({ kind: 'apple', crisp: true });

The emitter ties names to payloads

The emitter stores handlers in a map keyed by event name. Its on and emit methods read the payload type from the event map, so each call is checked per event.

interface AppEvents {
  joined: { userName: string };
  left: { userName: string };
  scored: { points: number };
}

type Handler<E, K extends keyof E> = (payload: E[K]) => void;

class Emitter<E> {
  private table = new Map<keyof E, Array<(payload: never) => void>>();

  on<K extends keyof E>(key: K, handler: Handler<E, K>): void {
    const list = this.table.get(key) ?? [];
    list.push(handler as (payload: never) => void);
    this.table.set(key, list);
  }

  emit<K extends keyof E>(key: K, payload: E[K]): void {
    for (const handler of this.table.get(key) ?? []) {
      handler(payload as never);
    }
  }
}

const emitter = new Emitter<AppEvents>();
emitter.on('joined', (payload) => console.log('joined: ' + payload.userName));
emitter.on('scored', (payload) => console.log('scored: ' + payload.points));
emitter.emit('joined', { userName: 'Ben' });
emitter.emit('scored', { points: 30 });
emitter.emit('left', { userName: 'Asha' });

Emitting with the wrong payload is refused

The scored event pairs its name with a numeric points member. Emitting it with a string points value is refused at the call.

interface AppEvents {
  joined: { userName: string };
  left: { userName: string };
  scored: { points: number };
}

type Handler<E, K extends keyof E> = (payload: E[K]) => void;

class Emitter<E> {
  private table = new Map<keyof E, Array<(payload: never) => void>>();

  on<K extends keyof E>(key: K, handler: Handler<E, K>): void {
    const list = this.table.get(key) ?? [];
    list.push(handler as (payload: never) => void);
    this.table.set(key, list);
  }

  emit<K extends keyof E>(key: K, payload: E[K]): void {
    for (const handler of this.table.get(key) ?? []) {
      handler(payload as never);
    }
  }
}

const emitter = new Emitter<AppEvents>();
emitter.emit('scored', { points: 'ten' });

Example

interface AppEvents {
  joined: { userName: string };
  left: { userName: string };
  scored: { points: number };
}

type Handler<E, K extends keyof E> = (payload: E[K]) => void;

type HandlerMap<E> = {
  [K in keyof E as `on${Capitalize<K & string>}`]: Handler<E, K>;
};

class Emitter<E> {
  private table = new Map<keyof E, Array<(payload: never) => void>>();

  on<K extends keyof E>(key: K, handler: Handler<E, K>): void {
    const list = this.table.get(key) ?? [];
    list.push(handler as (payload: never) => void);
    this.table.set(key, list);
  }

  emit<K extends keyof E>(key: K, payload: E[K]): void {
    for (const handler of this.table.get(key) ?? []) {
      handler(payload as never);
    }
  }
}

const handlers: HandlerMap<AppEvents> = {
  onJoined: (payload) => console.log('joined: ' + payload.userName),
  onLeft: (payload) => console.log('left: ' + payload.userName),
  onScored: (payload) => console.log('scored: ' + payload.points),
};

const emitter = new Emitter<AppEvents>();
emitter.on('joined', handlers.onJoined);
emitter.on('left', handlers.onLeft);
emitter.on('scored', handlers.onScored);
emitter.emit('joined', { userName: 'Asha' });
emitter.emit('scored', { points: 45 });
emitter.emit('left', { userName: 'Asha' });

Key points

  • The event map is a record that pairs each event name with the payload shape for that event.
  • A handler map remaps each event name into a handler name and types each handler by its payload.
  • Reading a payload by event name gives the emit and on methods their per-event types.
  • The in marker on a listener type states that the listener only consumes payloads and never produces one.
  • Handlers are stored inside the emitter under a shared never parameter shape, while the public methods stay typed per event.
  • Emitting an event with a payload of the wrong shape is a compile error at the call.

In this example, the full library runs end to end, with the remapped handler map registered on the emitter and three typed emit calls printing one line each in order.

Note

Every build in this library appeared earlier in the course, and the capstone only composes them.

Try it Yourself

Edit the code, then press Run.

Running is simulated ยท TypeScript Playground

Exercise

What does the emit method check at compile time?