Kurs JavaScript i TypeScript · Moduł 10: TypeScript w praktyce

Type-safe Event System w TypeScript

8 min czytania
W tej lekcji5

W Parku Jurajskim każdy system musi natychmiast reagować na zdarzenia: alarm o ucieczce raptorów, zmianę stanu ogrodzenia, nową pozycję dinozaura. Taki system zdarzeń (event system) wymaga precyzji, bo nie możesz wysłać danych o temperaturze do handlera, który czeka na współrzędne GPS. Zwykły emiter przyjmuje dowolny napis i dowolne dane, więc literówka w nazwie zdarzenia po cichu gubi alarm. TypeScript pozwala stworzyć w pełni typowany system eventów, w którym kompilator pilnuje poprawności za Ciebie.

EventMap - mapa typów zdarzeń

Fundamentem type-safe event systemu jest EventMap: interfejs mapujący nazwy zdarzeń na typy ich danych (payload). Nazwy w stylu dinosaur:escaped to zwykłe klucze napisowe, a dwukropek porządkuje je w grupy:

1// Definiujemy mapę zdarzeń parku
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 zna dokładny kształt danych dla każdego zdarzenia
11// 'dinosaur:escaped' => { species: string; paddock: string; dangerLevel: number }
12// 'fence:status' => { zone: string; voltage: number; active: boolean }

Sam interfejs nie generuje żadnego kodu. To umowa, z której skorzystają wszystkie metody emitera.

Generic EventEmitter z TypeScript

Teraz generyczny emiter. Parametr T to mapa zdarzeń, a każda metoda ma własny parametr K extends keyof T, czyli nazwę jednego zdarzenia, więc T[K] oznacza dokładnie payload tego zdarzenia:

1// Generyczny EventEmitter - T to mapa eventów
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)); // kopia: once() usuwa handler w trakcie pętli
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}

emit przechodzi po kopii tablicy handlerów, bo once usuwa swój wrapper w trakcie pętli, a na oryginale kolejny handler zostałby pominięty. Klasa nie zna żadnych konkretnych zdarzeń, wszystko wyczytuje z mapy. Wykrzyknik w this.listeners[event]! to asercja non-null: mówisz kompilatorowi, że tablica na pewno istnieje, bo linijkę wyżej ją utworzyłeś.

Podłączamy mapę parku. Parametr data w handlerze nie wymaga adnotacji, bo TypeScript wyprowadza jego typ z nazwy zdarzenia:

1// Użycie z ParkEventMap
2const parkEvents = new TypedEventEmitter<ParkEventMap>();
3
4// TypeScript zna typ parametru handler - auto-complete działa!
5parkEvents.on('dinosaur:escaped', (data) => {
6  // data jest typu { species: string; paddock: string; dangerLevel: number }
7  console.log(`ALARM! ${data.species} uciekł z ${data.paddock}!`);
8  console.log(`Poziom zagrożenia: ${data.dangerLevel}`);
9});
10
11parkEvents.on('fence:status', (data) => {
12  // data jest typu { zone: string; voltage: number; active: boolean }
13  if (!data.active) {
14    console.log(`Ogrodzenie w strefie ${data.zone} wyłączone!`);
15  }
16});

W pierwszym handlerze podpowiedzi pokażą species, paddock i dangerLevel, a w drugim zupełnie inne pola.

Wywołanie emit przyjmuje nazwę zdarzenia i pasujący do niej obiekt. Zobacz, co kompilator robi z błędnymi wywołaniami:

1// Poprawne emisje
2parkEvents.emit('dinosaur:escaped', {
3  species: 'Velociraptor',
4  paddock: 'B-7',
5  dangerLevel: 5
6});
7
8// Błąd kompilacji! Brakuje pola 'dangerLevel'
9// parkEvents.emit('dinosaur:escaped', { species: 'T-Rex', paddock: 'A-1' });
10
11// Błąd kompilacji! Nieznane zdarzenie
12// parkEvents.emit('dinosaur:sleeping', { species: 'Stegosaurus' });

Brakujące pole i nieznana nazwa zdarzenia to błędy kompilacji, a nie ciche awarie w działającym parku. To główna przewaga nad nietypowanym emiterem.

Pattern: Strongly-typed addEventListener

W definicjach typów DOM w TypeScripcie addEventListener jest typowany właśnie dzięki mapom zdarzeń, takim jak HTMLElementEventMap. Zbudujmy własną wersję tego wzorca, zaczynając od mapy i klasy bazowej:

1// Mapa eventów dla komponentu UI
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// Klasa komponentu z typowanymi eventami
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}

Domyślny parametr = UIEventMap sprawia, że zwykły komponent od razu zna podstawowe zdarzenia, a dispatchEvent jest protected, więc zdarzenia wysyła tylko sam komponent.

Mapa rozszerzona przez extends dodaje zdarzenia mapy parku, a klasa pochodna przekazuje ją jako parametr typu:

1// Rozszerzony komponent z własnymi eventami
2interface MapComponentEvents extends UIEventMap {
3  'marker:click': { markerId: string; lat: number; lng: number };
4  'zone:select': { zoneId: string; name: string };
5}
6
7class ParkMapComponent extends UIComponent<MapComponentEvents> {
8  selectZone(zoneId: string, name: string): void {
9    this.dispatchEvent('zone:select', { zoneId, name });
10  }
11
12  clickMarker(markerId: string, lat: number, lng: number): void {
13    this.dispatchEvent('marker:click', { markerId, lat, lng });
14  }
15}
16
17// Użycie
18const parkMap = new ParkMapComponent();
19
20parkMap.addEventListener('marker:click', (event) => {
21  // event: { markerId: string; lat: number; lng: number }
22  console.log(`Kliknięto marker ${event.markerId} na ${event.lat}, ${event.lng}`);
23});
24
25parkMap.addEventListener('click', (event) => {
26  // event: { x: number; y: number; target: string }
27  console.log(`Kliknięto na ${event.x}, ${event.y}`);
28});

parkMap zna zarówno własne zdarzenia (marker:click), jak i odziedziczone (click), a każdy handler dostaje właściwy typ.

Middleware pattern z typowanymi eventami

Zaawansowany wzorzec to middleware, który przetwarza zdarzenie przed dotarciem do handlera, jak śluza przed wybiegiem. reduceRight składa łańcuch od końca, więc middleware wykonują się w kolejności rejestracji:

1// Typ middleware - przetwarza event i decyduje czy go przepuścić
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    // Budujemy łańcuch middleware
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// Przykład: logowanie i walidacja eventów
40const securitySystem = new MiddlewareEventEmitter<ParkEventMap>();
41
42// Middleware logujący
43securitySystem.use('dinosaur:escaped', (data, next) => {
44  console.log(`[LOG] Zdarzenie ucieczki: ${data.species}`);
45  next(data);
46});
47
48// Middleware eskalujący poziom zagrożenia
49securitySystem.use('dinosaur:escaped', (data, next) => {
50  if (data.species === 'T-Rex' && data.dangerLevel < 5) {
51    next({ ...data, dangerLevel: 5 }); // Eskalacja!
52  } else {
53    next(data);
54  }
55});
56
57securitySystem.on('dinosaur:escaped', (data) => {
58  console.log(`Finalny alert: ${data.species}, zagrożenie: ${data.dangerLevel}`);
59});
60
61securitySystem.emit('dinosaur:escaped', {
62  species: 'T-Rex',
63  paddock: 'A-1',
64  dangerLevel: 3 // Zostanie eskalowany do 5 przez middleware
65});

Drugi middleware podnosi poziom zagrożenia T-Rexa do 5, zanim zobaczy go handler, a pierwotny obiekt zostaje nietknięty dzięki spreadowi.

Wyciąganie typów zdarzeń (utility types dla eventów)

Na koniec typy pomocnicze. EventsWithField łączy typ mapowany z typem warunkowym: zapis A extends B ? X : Y działa jak if w świecie typów, czyli sprawdza typ wejściowy, ocenia warunek extends i zwraca X albo Y:

1// Utility types do pracy z event mapami
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// Wyciągnięcie handlera dla konkretnego eventu
7type ParkEscapeHandler = EventHandler<ParkEventMap, 'dinosaur:escaped'>;
8// (data: { species: string; paddock: string; dangerLevel: number }) => void
9
10// Typ unii wszystkich payloadów
11type AnyParkPayload = ParkEventMap[keyof ParkEventMap];
12
13// Filtrowanie eventów po typie payloadu
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'

Typ warunkowy możesz połączyć z infer, żeby wyciągnąć fragment typu, na przykład type Return<T> = T extends (...args: any) => infer R ? R : never zwraca typ wyniku funkcji.

Type-safe event system to wzorzec, który sprawdza się wszędzie: od systemów monitoringu w Parku Jurajskim po frontend React, backendy Node.js i mikroserwisy. Generyczny TypedEventEmitter z EventMap daje pełne type safety, bo kompilator pilnuje, że emitujesz i nasłuchujesz dokładnie tych danych, które zadeklarowałeś. Polecam trzymać mapę zdarzeń w jednym pliku: to najlepsza dokumentacja komunikacji w systemie. W edytorze poniżej zbudujesz system monitoringu zdarzeń, a w kolejnej lekcji przeniesiesz TypeScript na serwer z Node.js.

Pamiętaj: mapa zdarzeń to rozkład częstotliwości radia parku, dzięki któremu każdy strażnik słyszy tylko komunikaty w formacie, którego się spodziewa.

Kod do tej lekcji: index.ts
1// Type-safe Event System w TypeScript
2// Park Jurajski - System Monitoringu Zdarzeń
3
4// 1. EventMap - mapa typów zdarzeń
5interface ParkEventMap {
6  'dinosaur:escaped': { species: string; paddock: string; dangerLevel: number };
7  'fence:status': { zone: string; voltage: number; active: boolean };
8  'visitor:enter': { visitorId: string; gate: string };
9  'alert:weather': { type: 'storm' | 'heat' | 'flood'; severity: 1 | 2 | 3 };
10}
11
12// 2. Generyczny TypedEventEmitter
13class TypedEventEmitter<T extends Record<string, any>> {
14  private listeners: { [K in keyof T]?: Array<(data: T[K]) => void> } = {};
15
16  on<K extends keyof T>(event: K, handler: (data: T[K]) => void): void {
17    if (!this.listeners[event]) {
18      this.listeners[event] = [];
19    }
20    this.listeners[event]!.push(handler);
21  }
22
23  off<K extends keyof T>(event: K, handler: (data: T[K]) => void): void {
24    const handlers = this.listeners[event];
25    if (handlers) {
26      const idx = handlers.indexOf(handler);
27      if (idx > -1) handlers.splice(idx, 1);
28    }
29  }
30
31  emit<K extends keyof T>(event: K, data: T[K]): void {
32    const handlers = this.listeners[event];
33    if (handlers) {
34      handlers.forEach(h => h(data));
35    }
36  }
37
38  once<K extends keyof T>(event: K, handler: (data: T[K]) => void): void {
39    const wrapper = (data: T[K]) => {
40      handler(data);
41      this.off(event, wrapper);
42    };
43    this.on(event, wrapper);
44  }
45}
46
47// 3. Utility types dla eventów
48type EventNames<T> = keyof T & string;
49type EventPayload<T, K extends keyof T> = T[K];
50type EventHandler<T, K extends keyof T> = (data: T[K]) => void;
51
52// 4. Test systemu
53const parkEvents = new TypedEventEmitter<ParkEventMap>();
54
55// Rejestracja handlerów z pełnym type safety
56parkEvents.on('dinosaur:escaped', (data) => {
57  // TypeScript wie: data = { species, paddock, dangerLevel }
58  console.log(`ALARM! ${data.species} uciekł z ${data.paddock}!`);
59  console.log(`   Poziom zagrożenia: ${data.dangerLevel}/5`);
60});
61
62parkEvents.on('fence:status', (data) => {
63  const status = data.active ? 'AKTYWNE' : 'WYŁĄCZONE';
64  console.log(`Ogrodzenie ${data.zone}: ${status} (${data.voltage}V)`);
65});
66
67parkEvents.on('visitor:enter', (data) => {
68  console.log(`Gość ${data.visitorId} wszedł bramą ${data.gate}`);
69});
70
71parkEvents.once('alert:weather', (data) => {
72  console.log(`Alert pogodowy (once): ${data.type}, stopień ${data.severity}`);
73});
74
75// Emisja zdarzeń
76console.log("=== Park Jurajski Event System ===\n");
77
78parkEvents.emit('visitor:enter', {
79  visitorId: 'VIS-042',
80  gate: 'Główna'
81});
82
83parkEvents.emit('fence:status', {
84  zone: 'Raptor Paddock',
85  voltage: 15000,
86  active: true
87});
88
89parkEvents.emit('dinosaur:escaped', {
90  species: 'Velociraptor',
91  paddock: 'B-7',
92  dangerLevel: 4
93});
94
95parkEvents.emit('alert:weather', {
96  type: 'storm',
97  severity: 2
98});
99
100// Drugi alert nie wywoła handlera (once!)
101parkEvents.emit('alert:weather', {
102  type: 'heat',
103  severity: 1
104});
105
106console.log("\n--- Drugi alert pogodowy nie został obsłużony (once) ---");
107
108// Poniższe NIE skompilowałoby się w TypeScript:
109// parkEvents.emit('dinosaur:escaped', { species: 'T-Rex' }); // Brak paddock i dangerLevel!
110// parkEvents.emit('unknown:event', {}); // Nieznane zdarzenie!
111
112console.log("\n=== Type-safe events działają! ===");

Widzisz błąd w tej lekcji?

Sprawdź się

Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.

  1. 1. Czym jest EventMap w kontekście type-safe event systemu?

  2. 2. Jaką zaletę daje generyczny TypedEventEmitter<T> w porównaniu z nietypowanym emitterem?

Zadania praktyczne w grze

  • Edytor kodu

    Zaimplementuj generyczny TypedEventEmitter z metodami on, emit i off.

  • Edytor kodu

    Stwórz EventMap z typowanymi payloadami i zarejestruj handlery.

  • Układanie w poziomie

    Ułóż elementy wywołania emit w typowanym emitterze:

  • Układanie w pionie

    Ułóż kroki ewaluacji conditional type T extends U ? X : Y:

  • Układanie w poziomie

    Ułóż elementy conditional type z infer:

Przydatne artykuły