Usamos cookies para mejorar tu experiencia en el sitio
CodeWorlds

Discriminated unions i pattern matching

"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."

Ir a CodeWorlds