Kurs JavaScript i React · Moduł 12: React i TypeScript
Discriminated Unions - Warianty Komponentów
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. Jakiego typu musi być pole dyskryminatora (np. kind) w discriminated union aby TypeScript mógł automatycznie zawężać typy?
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