Kurs JavaScript i React · Moduł 12: React i TypeScript

Discriminated Unions - Warianty Komponentów

5 min czytania
W tej lekcji5

Komponent karty obiektu kosmicznego dostaje raz planetę, raz gwiazdę, raz asteroidę. Wrzucasz wszystkie pola do jednego typu, oznaczasz je jako opcjonalne i po miesiącu nikt nie pamięta, czy gwiazda może mieć księżyce. W kosmosie różne obiekty wymagają różnego traktowania - planeta, gwiazda i asteroida to różne byty z różnymi właściwościami. Discriminated unions pozwalają TypeScript rozróżnić warianty obiektu na podstawie jednego pola.

Problem - różne kształty danych

Wyobraź sobie komponent, który wyświetla różne typy obiektów kosmicznych. Każdy typ ma inne pola. Najprostsze rozwiązanie wygląda niewinnie:

1// ŹLE - nie wiadomo które pola są dostępne
2interface SpaceObject {
3  type: string;
4  name: string;
5  radius?: number;
6  temperature?: number;
7  composition?: string[];
8  orbitalPeriod?: number;
9}
10
11function SpaceInfo({ obj }: { obj: SpaceObject }) {
12  // Musisz sprawdzać każde pole z osobna
13  if (obj.radius) { /* ... */ }
14  if (obj.temperature) { /* ... */ }
15  // Łatwo o błędy!
16}

Taki typ pozwala stworzyć "planetę" z temperaturą gwiazdy i bez promienia - kompilator nie ma jak tego wykryć, bo wszystko jest opcjonalne. Dodatkowo if (obj.radius) pominie planetę o promieniu 0, bo zero jest fałszywe.

Rozwiązanie - discriminated unions

Zamiast jednego interfejsu z opcjonalnymi polami tworzymy osobne interfejsy połączone wspólnym polem (dyskryminatorem). Kluczowe jest to, że dyskryminator ma typ literalny: nie dowolny string, tylko jeden konkretny napis, na przykład kind: "planet". To po tym napisie TypeScript rozpozna wariant.

1// Każdy typ ma wspólne pole 'kind' o różnej wartości
2interface PlanetData {
3  kind: "planet";
4  name: string;
5  radius: number;
6  habitable: boolean;
7  moons: number;
8}
9
10interface StarData {
11  kind: "star";
12  name: string;
13  temperature: number;
14  luminosity: number;
15  spectralClass: string;
16}
17
18interface AsteroidData {
19  kind: "asteroid";
20  name: string;
21  composition: string[];
22  diameter: number;
23}

Teraz łączymy trzy interfejsy znakiem | w jeden union type. Wartość typu SpaceObject jest dokładnie jednym z trzech wariantów, nigdy mieszanką:

1// Union type - obiekt może być jednym z trzech typów
2type SpaceObject = PlanetData | StarData | AsteroidData;

Planeta bez radius albo gwiazda z polem moons nie przejdą już kompilacji. Wszystkie pola w wariantach są wymagane - opcjonalność zniknęła, bo każdy wariant opisuje tylko to, co naprawdę ma.

Zawężanie typów (type narrowing)

TypeScript automatycznie zawęża typ na podstawie pola dyskryminatora. Gdy sprawdzisz obj.kind w instrukcji switch, wewnątrz każdego case kompilator wie, z którym wariantem pracujesz:

1function SpaceInfo({ obj }: { obj: SpaceObject }) {
2  // Wspólne pole - dostępne dla wszystkich wariantów
3  const header = <h2>{obj.name}</h2>;
4
5  // Switch na dyskryminator
6  switch (obj.kind) {
7    case "planet":
8      // TypeScript WIE że obj to PlanetData
9      return (
10        <div>
11          {header}
12          <p>Promień: {obj.radius} km</p>
13          <p>Księżyce: {obj.moons}</p>
14          <p>{obj.habitable ? "Zamieszkiwalna" : "Niezamieszkiwalna"}</p>
15        </div>
16      );
17    case "star":
18      // TypeScript WIE że obj to StarData
19      return (
20        <div>
21          {header}
22          <p>Temperatura: {obj.temperature}K</p>
23          <p>Klasa: {obj.spectralClass}</p>
24        </div>
25      );
26    case "asteroid":
27      // TypeScript WIE że obj to AsteroidData
28      return (
29        <div>
30          {header}
31          <p>Średnica: {obj.diameter} km</p>
32          <p>Skład: {obj.composition.join(", ")}</p>
33        </div>
34      );
35  }
36}

Przed switch dostępne jest tylko wspólne pole name. W gałęzi "star" próba odczytu obj.moons zakończy się błędem, bo gwiazdy nie mają księżyców. Co się NIE zmieniło: w działającym kodzie to zwykły switch na tekście - cała magia dzieje się w kompilatorze.

Warianty komponentów UI

Discriminated unions są idealne do tworzenia wariantów komponentów UI. Alert sukcesu potrzebuje tylko wiadomości, ale alert błędu musi dostać akcję ponowienia. Typ może to wymusić:

1// Warianty alertów
2type AlertProps =
3  | { variant: "success"; message: string }
4  | { variant: "error"; message: string; retryAction: () => void }
5  | { variant: "warning"; message: string; dismissable: boolean }
6  | { variant: "info"; message: string; link?: string };

Tym razem warianty są zapisane w miejscu, bez osobnych interfejsów, a dyskryminatorem jest variant. Komponent przyjmuje całe props bez destrukturyzacji, bo destrukturyzacja przed sprawdzeniem wariantu utrudnia zawężanie. Klasy CSS składamy szablonem tekstowym:

1function Alert(props: AlertProps) {
2  const baseClass = "alert";
3
4  switch (props.variant) {
5    case "success":
6      return <div className={`${baseClass} success`}>{props.message}</div>;
7    case "error":
8      return (
9        <div className={`${baseClass} error`}>
10          {props.message}
11          <button onClick={props.retryAction}>Ponów</button>
12        </div>
13      );
14    case "warning":
15      return (
16        <div className={`${baseClass} warning`}>
17          {props.message}
18          {props.dismissable && <button>Zamknij</button>}
19        </div>
20      );
21    case "info":
22      return (
23        <div className={`${baseClass} info`}>
24          {props.message}
25          {props.link && <a href={props.link}>Więcej</a>}
26        </div>
27      );
28  }
29}

Wywołanie <Alert variant="error" message="Awaria" /> bez retryAction zostanie odrzucone - komponent nie pozwoli zbudować alertu błędu bez przycisku ratunkowego.

Exhaustive checking - nigdy nie przegap wariantu

Co jeśli za rok ktoś doda wariant "comet" i zapomni go obsłużyć? Pomoże typ never, oznaczający wartość, która nie może istnieć. Gdy switch obsłuży wszystkie warianty, w gałęzi default zmienna ma typ never. Funkcja assertNever przyjmuje wyłącznie never, więc każdy nieobsłużony wariant staje się błędem kompilacji:

1// Funkcja pomocnicza - TypeScript zgłosi błąd jeśli nie obsłużysz wszystkich wariantów
2function assertNever(value: never): never {
3  throw new Error(`Nieobsłużony wariant: ${value}`);
4}
5
6function getLabel(obj: SpaceObject): string {
7  switch (obj.kind) {
8    case "planet": return "Planeta";
9    case "star": return "Gwiazda";
10    case "asteroid": return "Asteroida";
11    default: return assertNever(obj); // błąd jeśli dodasz nowy typ i nie obsłużysz go
12  }
13}

Po dodaniu CometData do unii TypeScript wskaże linię z assertNever, zanim kod trafi do załogi. throw zadziała tylko wtedy, gdyby niepoprawne dane przyszły z zewnątrz w trakcie działania. Moja rada: dodawaj assertNever w każdym switch po dyskryminatorze - to tania polisa. W następnej lekcji discriminated union opisze akcje w useReducer.

Kod do tej lekcji: App.tsx
1import React, { useState } from 'react';
2
3// Discriminated union - rozne obiekty kosmiczne
4interface PlanetData {
5  kind: "planet";
6  name: string;
7  radius: number;
8  habitable: boolean;
9  moons: number;
10}
11
12interface StarData {
13  kind: "star";
14  name: string;
15  temperature: number;
16  luminosity: number;
17  spectralClass: "O" | "B" | "A" | "F" | "G" | "K" | "M";
18}
19
20interface AsteroidData {
21  kind: "asteroid";
22  name: string;
23  diameter: number;
24  composition: string[];
25  dangerLevel: 1 | 2 | 3 | 4 | 5;
26}
27
28type SpaceObject = PlanetData | StarData | AsteroidData;
29
30// Komponent z type narrowing
31function SpaceCard({ obj }: { obj: SpaceObject }) {
32  const getIcon = (): string => {
33    switch (obj.kind) {
34      case "planet": return "●";
35      case "star": return "*";
36      case "asteroid": return "•";
37    }
38  };
39
40  const renderDetails = (): React.ReactNode => {
41    switch (obj.kind) {
42      case "planet":
43        return (
44          <>
45            <p>Promien: {obj.radius} km</p>
46            <p>Ksiezyce: {obj.moons}</p>
47            <span className={"badge " + (obj.habitable ? "green" : "red")}>
48              {obj.habitable ? "Zamieszkiwalna" : "Niezamieszkiwalna"}
49            </span>
50          </>
51        );
52      case "star":
53        return (
54          <>
55            <p>Temperatura: {obj.temperature}K</p>
56            <p>Jasnosc: {obj.luminosity}x Slonce</p>
57            <span className="badge blue">Klasa {obj.spectralClass}</span>
58          </>
59        );
60      case "asteroid":
61        return (
62          <>
63            <p>Srednica: {obj.diameter} km</p>
64            <p>Sklad: {obj.composition.join(", ")}</p>
65            <span className="badge orange">
66              Zagrozenie: {"!".repeat(obj.dangerLevel)}
67            </span>
68          </>
69        );
70    }
71  };
72
73  return (
74    <div className={"space-card " + obj.kind}>
75      <div className="card-header">
76        <span className="icon">{getIcon()}</span>
77        <h3>{obj.name}</h3>
78        <span className="kind">{obj.kind}</span>
79      </div>
80      <div className="card-body">{renderDetails()}</div>
81    </div>
82  );
83}
84
85function App() {
86  const [objects] = useState<SpaceObject[]>([
87    { kind: "planet", name: "Kepler-442b", radius: 8200, habitable: true, moons: 2 },
88    { kind: "star", name: "Betelgeuse", temperature: 3500, luminosity: 126000, spectralClass: "M" },
89    { kind: "asteroid", name: "Apophis", diameter: 370, composition: ["zelazo", "nikiel", "krzem"], dangerLevel: 4 },
90    { kind: "planet", name: "HD 209458 b", radius: 94000, habitable: false, moons: 0 },
91    { kind: "star", name: "Sirius", temperature: 9940, luminosity: 25, spectralClass: "A" },
92    { kind: "asteroid", name: "Ceres", diameter: 939, composition: ["lod", "skala", "mineraly"], dangerLevel: 1 },
93  ]);
94
95  const [filter, setFilter] = useState<SpaceObject["kind"] | "all">("all");
96
97  const filtered = filter === "all"
98    ? objects
99    : objects.filter(obj => obj.kind === filter);
100
101  return (
102    <div className="app">
103      <h1>Katalog Obiektow Kosmicznych</h1>
104      <div className="filters">
105        {(["all", "planet", "star", "asteroid"] as const).map(f => (
106          <button
107            key={f}
108            className={"filter-btn " + (filter === f ? "active" : "")}
109            onClick={() => setFilter(f)}
110          >
111            {f === "all" ? "Wszystkie" : f === "planet" ? "Planety" : f === "star" ? "Gwiazdy" : "Asteroidy"}
112          </button>
113        ))}
114      </div>
115      <div className="cards">
116        {filtered.map((obj, i) => <SpaceCard key={i} obj={obj} />)}
117      </div>
118    </div>
119  );
120}
121
122export default App;

Pamiętaj: dyskryminator to sygnał rozpoznawczy obiektu - odczytaj go, a TypeScript sam przełączy czujniki na właściwy wariant.

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. Jakiego typu musi być pole dyskryminatora (np. kind) w discriminated union aby TypeScript mógł automatycznie zawężać typy?

  2. 2. Co to jest discriminated union w TypeScript?

To 2 z 3 pytań do tej lekcji. Pozostałe rozwiążesz w grze.

Zadania praktyczne w grze

  • Układanie w pionie

    Ułóż kolejność kroków tworzenia i używania discriminated union:

  • Edytor kodu

    Discriminated union dla wariantów komponentu

  • Klikanie w kolejności

    Ułóż składnię funkcji assertNever do sprawdzania wyczerpującego (exhaustive checking):

  • Układanie w poziomie

    Ułóż składnię definicji union type z trzech interfejsów:

  • Edytor kodu

    Generyczny discriminated union dla stanów asynchronicznych

Przydatne artykuły