Kurs JavaScript i TypeScript · Moduł 7: Zaawansowany TypeScript

Discriminated unions i pattern matching

7 min czytania
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:

  1. Wspólne pole (tag/dyskryminator) musi mieć literalny typ (np. "active", nie string)
  2. Każdy wariant ma unikalną wartość tagu
  3. 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:

  1. Discriminated unions - każdy wariant ma wspólne pole (tag) z unikalną literalną wartością
  2. Pattern matching z switch - elegancka obsługa wszystkich wariantów
  3. Exhaustive handling z never - kompilator pilnuje, że obsłużyłeś każdy wariant
  4. Maszyny stanów - modelowanie dozwolonych przejść między stanami
  5. 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. 1. Co to jest discriminated union w TypeScript?

  2. 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:

Przydatne artykuły