Kurs JavaScript i TypeScript · Moduł 7: Zaawansowany TypeScript
Discriminated unions i pattern matching
W tej lekcji6
"W centrum kontroli Parku Jurajskiego każda sytuacja wymaga innej reakcji," wyjaśnia Claire Dearing, dyrektor operacyjna parku. "Ucieczka dinozaura, awaria zasilania, kontuzja gościa - każdy incydent ma swój protokół. Nie możemy pomylić jednego z drugim, bo konsekwencje byłyby katastrofalne. System musi sam rozpoznawać typ zdarzenia i kierować nas do właściwej procedury."
Discriminated unions (unie dyskryminowane) to potężny wzorzec TypeScript, w którym każdy wariant unii posiada wspólne pole - dyskryminator (tag) - jednoznacznie identyfikujące typ. W połączeniu z pattern matchingiem opartym o switch tworzą elegancki, bezpieczny i rozszerzalny system obsługi różnych stanów i zdarzeń.
Podstawy discriminated unions
Discriminated union to unia typów, w której każdy typ ma pole o tej samej nazwie (tag), ale z inną wartością literalną. TypeScript potrafi zawęzić typ na podstawie wartości tego pola.
1// Pole "status" jest dyskryminatorem
2interface ActiveDinosaur {
3 status: "active";
4 name: string;
5 enclosureId: string;
6 lastFed: Date;
7}
8
9interface SleepingDinosaur {
10 status: "sleeping";
11 name: string;
12 enclosureId: string;
13 sleepStarted: Date;
14 estimatedWakeUp: Date;
15}
16
17interface SickDinosaur {
18 status: "sick";
19 name: string;
20 enclosureId: string;
21 diagnosis: string;
22 veterinarianId: string;
23}
24
25type DinosaurState = ActiveDinosaur | SleepingDinosaur | SickDinosaur;
26
27// TypeScript zawęża typ po sprawdzeniu pola status
28function getDinosaurReport(dino: DinosaurState): string {
29 switch (dino.status) {
30 case "active":
31 // TypeScript wie, że to ActiveDinosaur - mamy dostęp do lastFed
32 return `${dino.name}: Aktywny, ostatnie karmienie: ${dino.lastFed.toLocaleString()}`;
33 case "sleeping":
34 // TypeScript wie, że to SleepingDinosaur - mamy dostęp do estimatedWakeUp
35 return `${dino.name}: Śpi, przewidywane przebudzenie: ${dino.estimatedWakeUp.toLocaleString()}`;
36 case "sick":
37 // TypeScript wie, że to SickDinosaur - mamy dostęp do diagnosis
38 return `${dino.name}: Chory - ${dino.diagnosis}, weterynarz: ${dino.veterinarianId}`;
39 }
40}Kluczowe aspekty discriminated unions:
- Wspólne pole (tag/dyskryminator) musi mieć literalny typ (np.
"active", niestring) - Każdy wariant ma unikalną wartość tagu
- TypeScript automatycznie zawęża typ po sprawdzeniu wartości tagu
Pattern matching z switch
Konstrukcja switch to naturalne narzędzie do pattern matchingu w TypeScript. Każdy case odpowiada jednemu wariantowi discriminated union.
1// System zdarzeń Parku Jurajskiego
2interface FeedingEvent {
3 type: "feeding";
4 dinosaurId: string;
5 food: string;
6 amountKg: number;
7 timestamp: Date;
8}
9
10interface EscapeEvent {
11 type: "escape";
12 dinosaurId: string;
13 enclosureId: string;
14 breachType: "fence" | "gate" | "underground";
15 timestamp: Date;
16}
17
18interface MaintenanceEvent {
19 type: "maintenance";
20 systemId: string;
21 description: string;
22 priority: "low" | "medium" | "high" | "critical";
23 timestamp: Date;
24}
25
26interface VisitorEvent {
27 type: "visitor";
28 visitorCount: number;
29 zone: string;
30 eventKind: "entry" | "exit" | "emergency";
31 timestamp: Date;
32}
33
34type ParkEvent = FeedingEvent | EscapeEvent | MaintenanceEvent | VisitorEvent;
35
36// Pattern matching - obsługa każdego typu zdarzenia
37function processParkEvent(event: ParkEvent): { action: string; urgency: number } {
38 switch (event.type) {
39 case "feeding":
40 return {
41 action: `Karmienie ${event.dinosaurId}: ${event.amountKg}kg ${event.food}`,
42 urgency: 1
43 };
44
45 case "escape":
46 return {
47 action: `ALARM! ${event.dinosaurId} uciekł z ${event.enclosureId} (typ: ${event.breachType})`,
48 urgency: 10
49 };
50
51 case "maintenance":
52 const urgencyMap = { low: 2, medium: 4, high: 7, critical: 9 };
53 return {
54 action: `Konserwacja ${event.systemId}: ${event.description}`,
55 urgency: urgencyMap[event.priority]
56 };
57
58 case "visitor":
59 const visitorUrgency = event.eventKind === "emergency" ? 8 : 1;
60 return {
61 action: `Goście (${event.visitorCount}) w ${event.zone}: ${event.eventKind}`,
62 urgency: visitorUrgency
63 };
64 }
65}Exhaustive handling - gwarancja obsługi wszystkich wariantów
Jedną z największych zalet discriminated unions jest możliwość exhaustive handling - kompilator może sprawdzić, czy obsłużyłeś WSZYSTKIE warianty. Jeśli dodasz nowy typ do unii, TypeScript natychmiast wskaże wszystkie miejsca, które wymagają aktualizacji.
1// Helper do exhaustive check
2function assertNever(value: never, message?: string): never {
3 throw new Error(message || `Nieobsłużony wariant: ${JSON.stringify(value)}`);
4}
5
6// Jeśli dodamy nowy typ zdarzenia do ParkEvent i zapomnimy go obsłużyć,
7// TypeScript zgłosi błąd w klauzuli default
8function processEventStrict(event: ParkEvent): string {
9 switch (event.type) {
10 case "feeding":
11 return "Karmienie";
12 case "escape":
13 return "Ucieczka";
14 case "maintenance":
15 return "Konserwacja";
16 case "visitor":
17 return "Goście";
18 default:
19 // Gdyby brakowało jednego case, TypeScript zgłosi błąd:
20 // "Type 'XXX' is not assignable to type 'never'"
21 return assertNever(event);
22 }
23}Maszyny stanów z discriminated unions
Discriminated unions doskonale nadają się do modelowania maszyn stanów (state machines), gdzie obiekt może znajdować się w jednym z kilku stanów, a przejścia między stanami są ściśle kontrolowane.
1// Stany zagrody dinozaurów jako maszyna stanów
2interface EnclosureNormal {
3 state: "normal";
4 dinosaurCount: number;
5 securityLevel: number;
6}
7
8interface EnclosureLockdown {
9 state: "lockdown";
10 dinosaurCount: number;
11 securityLevel: number;
12 reason: string;
13 lockdownSince: Date;
14}
15
16interface EnclosureBreach {
17 state: "breach";
18 dinosaurCount: number;
19 escapedCount: number;
20 breachLocation: string;
21 responseTeamDispatched: boolean;
22}
23
24interface EnclosureMaintenance {
25 state: "maintenance";
26 dinosaurCount: 0; // Zagroda musi być pusta podczas konserwacji
27 scheduledEnd: Date;
28 maintenanceType: string;
29}
30
31type EnclosureState = EnclosureNormal | EnclosureLockdown | EnclosureBreach | EnclosureMaintenance;
32
33// Dozwolone przejścia między stanami
34function transitionEnclosure(
35 current: EnclosureState,
36 action: "lockdown" | "breach" | "repair" | "resolve"
37): EnclosureState {
38 switch (current.state) {
39 case "normal":
40 if (action === "lockdown") {
41 return {
42 state: "lockdown",
43 dinosaurCount: current.dinosaurCount,
44 securityLevel: current.securityLevel + 5,
45 reason: "Procedura bezpieczeństwa",
46 lockdownSince: new Date()
47 };
48 }
49 if (action === "repair") {
50 if (current.dinosaurCount > 0) {
51 throw new Error("Nie można rozpocząć konserwacji - zagroda zajęta!");
52 }
53 return {
54 state: "maintenance",
55 dinosaurCount: 0,
56 scheduledEnd: new Date(Date.now() + 86400000),
57 maintenanceType: "Standardowy przegląd"
58 };
59 }
60 return current;
61
62 case "lockdown":
63 if (action === "breach") {
64 return {
65 state: "breach",
66 dinosaurCount: current.dinosaurCount - 1,
67 escapedCount: 1,
68 breachLocation: "Sektor nieznany",
69 responseTeamDispatched: false
70 };
71 }
72 if (action === "resolve") {
73 return {
74 state: "normal",
75 dinosaurCount: current.dinosaurCount,
76 securityLevel: current.securityLevel
77 };
78 }
79 return current;
80
81 case "breach":
82 if (action === "resolve") {
83 return {
84 state: "lockdown",
85 dinosaurCount: current.dinosaurCount + current.escapedCount,
86 securityLevel: 10,
87 reason: "Post-breach lockdown",
88 lockdownSince: new Date()
89 };
90 }
91 return current;
92
93 case "maintenance":
94 if (action === "resolve") {
95 return {
96 state: "normal",
97 dinosaurCount: 0,
98 securityLevel: 5
99 };
100 }
101 return current;
102 }
103}
104
105// Test maszyny stanów
106let enclosure: EnclosureState = {
107 state: "normal",
108 dinosaurCount: 3,
109 securityLevel: 5
110};
111
112console.log("Stan początkowy:", enclosure.state);
113enclosure = transitionEnclosure(enclosure, "lockdown");
114console.log("Po lockdown:", enclosure.state);
115enclosure = transitionEnclosure(enclosure, "breach");
116console.log("Po breach:", enclosure.state);
117if (enclosure.state === "breach") {
118 console.log("Uciekło dinozaurów:", enclosure.escapedCount);
119}Odpowiedzi API jako discriminated unions
Częstym zastosowaniem w prawdziwych aplikacjach jest modelowanie odpowiedzi API za pomocą discriminated unions:
1// Generyczny typ odpowiedzi API
2interface SuccessResponse<T> {
3 status: "success";
4 data: T;
5 timestamp: Date;
6}
7
8interface ErrorResponse {
9 status: "error";
10 errorCode: number;
11 message: string;
12 timestamp: Date;
13}
14
15interface LoadingResponse {
16 status: "loading";
17 progress: number; // 0-100
18}
19
20type ApiResponse<T> = SuccessResponse<T> | ErrorResponse | LoadingResponse;
21
22// Typ danych dinozaura z API
23interface DinosaurApiData {
24 id: string;
25 name: string;
26 species: string;
27 weight: number;
28}
29
30// Obsługa odpowiedzi z pełnym bezpieczeństwem typów
31function renderDinosaurData(response: ApiResponse<DinosaurApiData>): string {
32 switch (response.status) {
33 case "loading":
34 return `Ładowanie... ${response.progress}%`;
35 case "error":
36 return `Błąd ${response.errorCode}: ${response.message}`;
37 case "success":
38 const { name, species, weight } = response.data;
39 return `${name} (${species}) - ${weight} kg`;
40 }
41}
42
43// Testowe odpowiedzi
44const loading: ApiResponse<DinosaurApiData> = { status: "loading", progress: 45 };
45const success: ApiResponse<DinosaurApiData> = {
46 status: "success",
47 data: { id: "D-001", name: "Rexy", species: "T-Rex", weight: 8000 },
48 timestamp: new Date()
49};
50const error: ApiResponse<DinosaurApiData> = {
51 status: "error",
52 errorCode: 404,
53 message: "Dinozaur nie znaleziony",
54 timestamp: new Date()
55};
56
57console.log(renderDinosaurData(loading));
58console.log(renderDinosaurData(success));
59console.log(renderDinosaurData(error));Podsumowanie
Discriminated unions i pattern matching to jedne z najpotężniejszych wzorców w TypeScript:
- Discriminated unions - każdy wariant ma wspólne pole (tag) z unikalną literalną wartością
- Pattern matching z switch - elegancka obsługa wszystkich wariantów
- Exhaustive handling z never - kompilator pilnuje, że obsłużyłeś każdy wariant
- Maszyny stanów - modelowanie dozwolonych przejść między stanami
- Odpowiedzi API - typowanie success/error/loading z pełnym bezpieczeństwem
Jak mówi Claire Dearing: "W Parku Jurajskim każdy scenariusz ma swój protokół. Z discriminated unions TypeScript pilnuje, że żaden scenariusz nie zostanie pominięty. To jak system wczesnego ostrzegania - nie pozwala na przeoczenie żadnego zagrożenia."
Kod do tej lekcji: index.ts
1// Discriminated unions i pattern matching - Park Jurajski
2console.log("=== DISCRIMINATED UNIONS ===\n");
3
4// ===========================================
5// 1. Podstawowy discriminated union
6// ===========================================
7
8interface ActiveDino {
9 status: "active";
10 name: string;
11 lastFed: string;
12}
13
14interface SleepingDino {
15 status: "sleeping";
16 name: string;
17 wakeUpAt: string;
18}
19
20interface SickDino {
21 status: "sick";
22 name: string;
23 diagnosis: string;
24}
25
26type DinoState = ActiveDino | SleepingDino | SickDino;
27
28function getReport(dino: DinoState): string {
29 switch (dino.status) {
30 case "active": return `${dino.name}: aktywny, karmiony ${dino.lastFed}`;
31 case "sleeping": return `${dino.name}: spi, obudzi sie ${dino.wakeUpAt}`;
32 case "sick": return `${dino.name}: chory - ${dino.diagnosis}`;
33 }
34}
35
36console.log("--- Stan dinozaurow ---");
37console.log(getReport({ status: "active", name: "Rexy", lastFed: "10:00" }));
38console.log(getReport({ status: "sleeping", name: "Blue", wakeUpAt: "15:00" }));
39console.log(getReport({ status: "sick", name: "Cera", diagnosis: "Goraczka" }));
40
41// ===========================================
42// 2. System zdarzen z pattern matching
43// ===========================================
44
45interface FeedEvent { type: "feed"; dinoId: string; food: string; kg: number; }
46interface EscapeEvent { type: "escape"; dinoId: string; zone: string; }
47interface MaintenanceEvent { type: "maintenance"; system: string; priority: "low" | "high"; }
48
49type ParkEvent = FeedEvent | EscapeEvent | MaintenanceEvent;
50
51function processEvent(event: ParkEvent): { action: string; urgency: number } {
52 switch (event.type) {
53 case "feed":
54 return { action: `Karmienie ${event.dinoId}: ${event.kg}kg ${event.food}`, urgency: 1 };
55 case "escape":
56 return { action: `ALARM! ${event.dinoId} uciekl z ${event.zone}!`, urgency: 10 };
57 case "maintenance":
58 return { action: `Konserwacja: ${event.system}`, urgency: event.priority === "high" ? 7 : 3 };
59 }
60}
61
62console.log("\n--- Zdarzenia ---");
63const events: ParkEvent[] = [
64 { type: "feed", dinoId: "D-01", food: "Koza", kg: 50 },
65 { type: "escape", dinoId: "D-03", zone: "Sektor B" },
66 { type: "maintenance", system: "Ogrodzenie A", priority: "high" },
67];
68events.forEach(e => {
69 const r = processEvent(e);
70 console.log(`[${r.urgency}] ${r.action}`);
71});
72
73// ===========================================
74// 3. Exhaustive check z never
75// ===========================================
76
77function assertNever(value: never, msg?: string): never {
78 throw new Error(msg || "Nieobsluzony wariant: " + JSON.stringify(value));
79}
80
81function strictProcess(event: ParkEvent): string {
82 switch (event.type) {
83 case "feed": return "Karmienie";
84 case "escape": return "Ucieczka";
85 case "maintenance": return "Konserwacja";
86 default: return assertNever(event);
87 }
88}
89
90console.log("\n--- Exhaustive check ---");
91console.log("Wszystkie warianty obslugzone:", strictProcess(events[0]));
92
93// ===========================================
94// 4. Maszyna stanow zagrody
95// ===========================================
96
97interface NormalState { state: "normal"; dinoCount: number; }
98interface LockdownState { state: "lockdown"; dinoCount: number; reason: string; }
99interface BreachState { state: "breach"; escaped: number; }
100
101type EncState = NormalState | LockdownState | BreachState;
102
103function transition(current: EncState, action: "lock" | "breach" | "resolve"): EncState {
104 switch (current.state) {
105 case "normal":
106 if (action === "lock") return { state: "lockdown", dinoCount: current.dinoCount, reason: "Procedura" };
107 return current;
108 case "lockdown":
109 if (action === "breach") return { state: "breach", escaped: 1 };
110 if (action === "resolve") return { state: "normal", dinoCount: current.dinoCount };
111 return current;
112 case "breach":
113 if (action === "resolve") return { state: "lockdown", dinoCount: 0, reason: "Post-breach" };
114 return current;
115 }
116}
117
118console.log("\n--- Maszyna stanow ---");
119let enc: EncState = { state: "normal", dinoCount: 3 };
120console.log("Start:", enc.state);
121enc = transition(enc, "lock");
122console.log("Po lock:", enc.state);
123enc = transition(enc, "breach");
124console.log("Po breach:", enc.state);
125enc = transition(enc, "resolve");
126console.log("Po resolve:", enc.state);
127
128// ===========================================
129// 5. API Response pattern
130// ===========================================
131
132interface Success<T> { status: "ok"; data: T; }
133interface Err { status: "error"; code: number; message: string; }
134interface Loading { status: "loading"; progress: number; }
135
136type ApiResponse<T> = Success<T> | Err | Loading;
137
138function render(res: ApiResponse<{ name: string }>): string {
139 switch (res.status) {
140 case "loading": return `Ladowanie... ${res.progress}%`;
141 case "error": return `Blad ${res.code}: ${res.message}`;
142 case "ok": return `Dane: ${res.data.name}`;
143 }
144}
145
146console.log("\n--- API Response ---");
147console.log(render({ status: "loading", progress: 50 }));
148console.log(render({ status: "ok", data: { name: "Rexy" } }));
149console.log(render({ status: "error", code: 404, message: "Nie znaleziono" }));
150
151console.log("\nDiscriminated unions = bezpieczne pattern matching!");Widzisz błąd w tej lekcji?
Sprawdź się
Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.
1. Co to jest discriminated union w TypeScript?
2. Do czego służy typ never w klauzuli default switch'a przy obsłudze discriminated union?
To 2 z 4 pytań do tej lekcji. Pozostałe rozwiążesz w grze.
Zadania praktyczne w grze
- Układanie w poziomie
Ułóż elementy typu discriminated union:
- Edytor kodu
Stwórz discriminated union z pattern matchingiem i exhaustive check dla zdarzeń parku
- Układanie w poziomie
Ułóż elementy użycia Omit:
- Układanie w poziomie
Ułóż elementy użycia Record:
- Edytor kodu
Stwórz kompletny system typów dla zarządzania parkiem
- Układanie w poziomie
Ułóż elementy użycia Required:
- Układanie w poziomie
Ułóż elementy Pick type:
- Układanie w pionie
Uporządkuj ewolucję od prostego typu do zaawansowanego utility type: