We use cookies to enhance your experience on the site
CodeWorlds

Type-safe Event System in TypeScript

In Jurassic Park, every system must immediately react to events — an alarm about a raptor escape, a fence status change, a dinosaur position update. Such an event system requires precision — you cannot send temperature data to a handler expecting GPS coordinates. TypeScript allows you to create a fully typed event system where the compiler ensures correctness for you.

EventMap — Event Type Map

The foundation of a type-safe event system is an EventMap — an interface mapping event names to their data types (payload):

1// Defining the park event map
2interface ParkEventMap {
3  'dinosaur:escaped': { species: string; paddock: string; dangerLevel: number };
4  'fence:status': { zone: string; voltage: number; active: boolean };
5  'visitor:enter': { visitorId: string; gate: string; timestamp: Date };
6  'visitor:exit': { visitorId: string; gate: string; timestamp: Date };
7  'alert:weather': { type: 'storm' | 'heat' | 'flood'; severity: 1 | 2 | 3 };
8}
9
10// TypeScript knows the exact shape of data for each event
11// 'dinosaur:escaped' => { species: string; paddock: string; dangerLevel: number }
12// 'fence:status' => { zone: string; voltage: number; active: boolean }

Generic EventEmitter with TypeScript

Now let's build a generic event emitter that enforces type consistency:

1// Generic EventEmitter - T is the event map
2class TypedEventEmitter<T extends Record<string, any>> {
3  private listeners: {
4    [K in keyof T]?: Array<(data: T[K]) => void>;
5  } = {};
6
7  on<K extends keyof T>(event: K, handler: (data: T[K]) => void): void {
8    if (!this.listeners[event]) {
9      this.listeners[event] = [];
10    }
11    this.listeners[event]!.push(handler);
12  }
13
14  off<K extends keyof T>(event: K, handler: (data: T[K]) => void): void {
15    const handlers = this.listeners[event];
16    if (handlers) {
17      const index = handlers.indexOf(handler);
18      if (index > -1) handlers.splice(index, 1);
19    }
20  }
21
22  emit<K extends keyof T>(event: K, data: T[K]): void {
23    const handlers = this.listeners[event];
24    if (handlers) {
25      handlers.forEach(handler => handler(data));
26    }
27  }
28
29  once<K extends keyof T>(event: K, handler: (data: T[K]) => void): void {
30    const wrapper = (data: T[K]) => {
31      handler(data);
32      this.off(event, wrapper);
33    };
34    this.on(event, wrapper);
35  }
36}
37
38// Usage with ParkEventMap
39const parkEvents = new TypedEventEmitter<ParkEventMap>();
40
41// TypeScript knows the handler parameter type — auto-complete works!
42parkEvents.on('dinosaur:escaped', (data) => {
43  // data is of type { species: string; paddock: string; dangerLevel: number }
44  console.log(`ALARM! ${data.species} escaped from ${data.paddock}!`);
45  console.log(`Danger level: ${data.dangerLevel}`);
46});
47
48parkEvents.on('fence:status', (data) => {
49  // data is of type { zone: string; voltage: number; active: boolean }
50  if (!data.active) {
51    console.log(`Fence in zone ${data.zone} deactivated!`);
52  }
53});
54
55// Correct emissions
56parkEvents.emit('dinosaur:escaped', {
57  species: 'Velociraptor',
58  paddock: 'B-7',
59  dangerLevel: 5
60});
61
62// Compilation error! Missing field 'dangerLevel'
63// parkEvents.emit('dinosaur:escaped', { species: 'T-Rex', paddock: 'A-1' });
64
65// Compilation error! Unknown event
66// parkEvents.emit('dinosaur:sleeping', { species: 'Stegosaurus' });

Pattern: Strongly-typed addEventListener

In browsers,

addEventListener
is typed precisely thanks to event maps. Let's build our own version of this pattern:

1// Event map for a UI component
2interface UIEventMap {
3  click: { x: number; y: number; target: string };
4  hover: { x: number; y: number; element: string };
5  keypress: { key: string; code: number; ctrl: boolean; shift: boolean };
6  resize: { width: number; height: number };
7  scroll: { scrollTop: number; scrollLeft: number };
8}
9
10// Component class with typed events
11class UIComponent<Events extends Record<string, any> = UIEventMap> {
12  private eventHandlers: Map<keyof Events, Set<Function>> = new Map();
13
14  addEventListener<K extends keyof Events>(
15    type: K,
16    listener: (event: Events[K]) => void
17  ): void {
18    if (!this.eventHandlers.has(type)) {
19      this.eventHandlers.set(type, new Set());
20    }
21    this.eventHandlers.get(type)!.add(listener);
22  }
23
24  removeEventListener<K extends keyof Events>(
25    type: K,
26    listener: (event: Events[K]) => void
27  ): void {
28    this.eventHandlers.get(type)?.delete(listener);
29  }
30
31  protected dispatchEvent<K extends keyof Events>(
32    type: K,
33    event: Events[K]
34  ): void {
35    this.eventHandlers.get(type)?.forEach(handler => {
36      (handler as (event: Events[K]) => void)(event);
37    });
38  }
39}
40
41// Extended component with custom events
42interface MapComponentEvents extends UIEventMap {
43  'marker:click': { markerId: string; lat: number; lng: number };
44  'zone:select': { zoneId: string; name: string };
45}
46
47class ParkMapComponent extends UIComponent<MapComponentEvents> {
48  selectZone(zoneId: string, name: string): void {
49    this.dispatchEvent('zone:select', { zoneId, name });
50  }
51
52  clickMarker(markerId: string, lat: number, lng: number): void {
53    this.dispatchEvent('marker:click', { markerId, lat, lng });
54  }
55}
56
57// Usage
58const parkMap = new ParkMapComponent();
59
60parkMap.addEventListener('marker:click', (event) => {
61  // event: { markerId: string; lat: number; lng: number }
62  console.log(`Clicked marker ${event.markerId} at ${event.lat}, ${event.lng}`);
63});
64
65parkMap.addEventListener('click', (event) => {
66  // event: { x: number; y: number; target: string }
67  console.log(`Clicked at ${event.x}, ${event.y}`);
68});

Middleware Pattern with Typed Events

An advanced pattern — middleware that processes events before they reach the handler:

1// Middleware type — processes event and decides whether to pass it through
2type EventMiddleware<T> = (
3  data: T,
4  next: (data: T) => void
5) => void;
6
7class MiddlewareEventEmitter<T extends Record<string, any>> {
8  private listeners: { [K in keyof T]?: Array<(data: T[K]) => void> } = {};
9  private middleware: { [K in keyof T]?: Array<EventMiddleware<T[K]>> } = {};
10
11  on<K extends keyof T>(event: K, handler: (data: T[K]) => void): void {
12    if (!this.listeners[event]) this.listeners[event] = [];
13    this.listeners[event]!.push(handler);
14  }
15
16  use<K extends keyof T>(event: K, mw: EventMiddleware<T[K]>): void {
17    if (!this.middleware[event]) this.middleware[event] = [];
18    this.middleware[event]!.push(mw);
19  }
20
21  emit<K extends keyof T>(event: K, data: T[K]): void {
22    const mws = this.middleware[event] || [];
23    const handlers = this.listeners[event] || [];
24
25    // Building the middleware chain
26    const chain = mws.reduceRight(
27      (next: (data: T[K]) => void, mw: EventMiddleware<T[K]>) => {
28        return (d: T[K]) => mw(d, next);
29      },
30      (finalData: T[K]) => {
31        handlers.forEach(h => h(finalData));
32      }
33    );
34
35    chain(data);
36  }
37}
38
39// Example: logging and validating events
40const securitySystem = new MiddlewareEventEmitter<ParkEventMap>();
41
42// Logging middleware
43securitySystem.use('dinosaur:escaped', (data, next) => {
44  console.log(`[LOG] Escape event: ${data.species}`);
45  next(data);
46});
47
48// Danger level escalation middleware
49securitySystem.use('dinosaur:escaped', (data, next) => {
50  if (data.species === 'T-Rex' && data.dangerLevel < 5) {
51    next({ ...data, dangerLevel: 5 }); // Escalation!
52  } else {
53    next(data);
54  }
55});
56
57securitySystem.on('dinosaur:escaped', (data) => {
58  console.log(`Final alert: ${data.species}, danger: ${data.dangerLevel}`);
59});
60
61securitySystem.emit('dinosaur:escaped', {
62  species: 'T-Rex',
63  paddock: 'A-1',
64  dangerLevel: 3 // Will be escalated to 5 by middleware
65});

Extracting Event Types (Utility Types for Events)

1// Utility types for working with event maps
2type EventNames<T> = keyof T & string;
3type EventPayload<T, K extends keyof T> = T[K];
4type EventHandler<T, K extends keyof T> = (data: T[K]) => void;
5
6// Extracting handler for a specific event
7type ParkEscapeHandler = EventHandler<ParkEventMap, 'dinosaur:escaped'>;
8// (data: { species: string; paddock: string; dangerLevel: number }) => void
9
10// Union type of all payloads
11type AnyParkPayload = ParkEventMap[keyof ParkEventMap];
12
13// Filtering events by payload type
14type EventsWithField<T, Field extends string> = {
15  [K in keyof T]: Field extends keyof T[K] ? K : never;
16}[keyof T];
17
18type EventsWithVisitorId = EventsWithField<ParkEventMap, 'visitorId'>;
19// 'visitor:enter' | 'visitor:exit'

A type-safe event system is a pattern that works everywhere — from monitoring systems in Jurassic Park to React frontends, Node.js backends, and microservices. A generic

TypedEventEmitter
with an
EventMap
provides full type safety — the compiler ensures that you emit and listen for exactly the data you declared.

Go to CodeWorlds