Kurs JavaScript i TypeScript · Moduł 10: TypeScript w praktyce
Type-safe Event System w TypeScript
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. Czym jest EventMap w kontekście type-safe event systemu?
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: