Cours JavaScript et React · Module 12: React et TypeScript

Unions discriminées - variantes de composants

6 min de lecture
Dans cette leçon5

Une carte d'objet cosmique reçoit tantôt une planète, tantôt une étoile, tantôt un astéroïde. Tu mets tous les champs dans un seul type, tu les marques comme optionnels, et un mois plus tard plus personne ne se souvient si une étoile peut avoir des lunes. Dans l'espace, des objets différents exigent des traitements différents - une planète, une étoile et un astéroïde sont des entités distinctes aux propriétés distinctes. Les unions discriminées permettent à TypeScript de distinguer les variantes d'un objet grâce à un seul champ.

Le problème - des formes de données différentes

Imagine un composant qui affiche différents types d'objets cosmiques. Chaque type possède des champs différents. La solution la plus simple a l'air innocente :

1// MAUVAIS - on ne sait pas clairement quels champs sont disponibles
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  // Tu dois vérifier chaque champ séparément
13  if (obj.radius) { /* ... */ }
14  if (obj.temperature) { /* ... */ }
15  // Facile de se tromper !
16}

Ce type permet de créer une "planète" avec la température d'une étoile et sans rayon - le compilateur ne peut pas le détecter, puisque tout est optionnel. En plus, if (obj.radius) ignore une planète de rayon 0, car zéro est une valeur fausse.

La solution - les unions discriminées

Au lieu d'une seule interface aux champs optionnels, on crée des interfaces séparées reliées par un champ commun (le discriminant). L'essentiel est que le discriminant a un type littéral : pas n'importe quel string, mais une chaîne précise, par exemple kind: "planet". C'est grâce à cette chaîne que TypeScript reconnaît la variante.

1// Chaque type a un champ 'kind' commun avec une valeur différente
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}

On réunit maintenant les trois interfaces avec | en un seul type union. Une valeur de type SpaceObject est exactement l'une des trois variantes, jamais un mélange :

1// Type union - l'objet peut être l'un des trois types
2type SpaceObject = PlanetData | StarData | AsteroidData;

Une planète sans radius ou une étoile avec un champ moons ne compile plus. Tous les champs des variantes sont obligatoires - l'optionnalité a disparu, car chaque variante ne décrit que ce qu'elle possède vraiment.

Le rétrécissement de type (type narrowing)

TypeScript rétrécit automatiquement le type en fonction du champ discriminant. Quand tu vérifies obj.kind dans une instruction switch, à l'intérieur de chaque case le compilateur sait avec quelle variante tu travailles :

1function SpaceInfo({ obj }: { obj: SpaceObject }) {
2  // Champ commun - disponible pour toutes les variantes
3  const header = <h2>{obj.name}</h2>;
4
5  // Switch sur le discriminant
6  switch (obj.kind) {
7    case "planet":
8      // TypeScript SAIT que obj est PlanetData
9      return (
10        <div>
11          {header}
12          <p>Radius: {obj.radius} km</p>
13          <p>Moons: {obj.moons}</p>
14          <p>{obj.habitable ? "Habitable" : "Uninhabitable"}</p>
15        </div>
16      );
17    case "star":
18      // TypeScript SAIT que obj est StarData
19      return (
20        <div>
21          {header}
22          <p>Temperature: {obj.temperature}K</p>
23          <p>Class: {obj.spectralClass}</p>
24        </div>
25      );
26    case "asteroid":
27      // TypeScript SAIT que obj est AsteroidData
28      return (
29        <div>
30          {header}
31          <p>Diameter: {obj.diameter} km</p>
32          <p>Composition: {obj.composition.join(", ")}</p>
33        </div>
34      );
35  }
36}

Avant le switch, seul le champ commun name est disponible. Dans la branche "star", lire obj.moons provoque une erreur, car les étoiles n'ont pas de lunes. Ce qui n'a PAS changé : dans le code exécuté, c'est un switch ordinaire sur une chaîne - toute la magie se passe dans le compilateur.

Variantes de composants UI

Les unions discriminées sont idéales pour créer des variantes de composants UI. Une alerte de succès n'a besoin que d'un message, mais une alerte d'erreur doit recevoir une action de nouvelle tentative. Le type peut l'imposer :

1// Variantes d'alerte
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 };

Cette fois, les variantes sont écrites sur place, sans interfaces séparées, et le discriminant est variant. Le composant prend props en entier sans déstructuration, car déstructurer avant de vérifier la variante complique le rétrécissement. On construit les classes CSS avec une chaîne de gabarit :

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}>Retry</button>
12        </div>
13      );
14    case "warning":
15      return (
16        <div className={`${baseClass} warning`}>
17          {props.message}
18          {props.dismissable && <button>Dismiss</button>}
19        </div>
20      );
21    case "info":
22      return (
23        <div className={`${baseClass} info`}>
24          {props.message}
25          {props.link && <a href={props.link}>Learn more</a>}
26        </div>
27      );
28  }
29}

L'appel <Alert variant="error" message="Failure" /> sans retryAction est refusé - le composant ne te laisse pas construire une alerte d'erreur sans bouton de secours.

Vérification exhaustive - ne jamais oublier une variante

Et si dans un an quelqu'un ajoute une variante "comet" et oublie de la gérer ? Le type never vient à la rescousse : il désigne une valeur qui ne peut pas exister. Quand un switch gère toutes les variantes, la variable dans la branche default a le type never. La fonction assertNever n'accepte que never, donc chaque variante non gérée devient une erreur de compilation :

1// Fonction utilitaire - TypeScript signalera une erreur si tu ne gères pas toutes les variantes
2function assertNever(value: never): never {
3  throw new Error(`Unhandled variant: ${value}`);
4}
5
6function getLabel(obj: SpaceObject): string {
7  switch (obj.kind) {
8    case "planet": return "Planet";
9    case "star": return "Star";
10    case "asteroid": return "Asteroid";
11    default: return assertNever(obj); // erreur si tu ajoutes un nouveau type et ne le gères pas
12  }
13}

Après l'ajout de CometData à l'union, TypeScript pointe la ligne avec assertNever avant que le code n'atteigne l'équipage. Le throw ne se déclenche que si des données invalides arrivent de l'extérieur à l'exécution. Mon conseil : ajoute assertNever à chaque switch sur un discriminant - c'est une assurance bon marché. Dans la prochaine leçon, une union discriminée décrira les actions de useReducer.

Code de cette leçon : App.tsx
1import React, { useState } from 'react';
2
3// Union discriminée - différents objets cosmiques
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// Composant avec rétrécissement de type
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>Rayon : {obj.radius} km</p>
46            <p>Lunes : {obj.moons}</p>
47            <span className={"badge " + (obj.habitable ? "green" : "red")}>
48              {obj.habitable ? "Habitable" : "Inhabitable"}
49            </span>
50          </>
51        );
52      case "star":
53        return (
54          <>
55            <p>Température : {obj.temperature}K</p>
56            <p>Luminosité : {obj.luminosity}x le Soleil</p>
57            <span className="badge blue">Classe {obj.spectralClass}</span>
58          </>
59        );
60      case "asteroid":
61        return (
62          <>
63            <p>Diamètre : {obj.diameter} km</p>
64            <p>Composition : {obj.composition.join(", ")}</p>
65            <span className="badge orange">
66              Danger : {"!".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: ["fer", "nickel", "silicium"], 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: ["glace", "roche", "minéraux"], 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>Catalogue des objets cosmiques</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" ? "Toutes" : f === "planet" ? "Planètes" : f === "star" ? "Étoiles" : "Astéroïdes"}
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;

Retiens : le discriminant est le signal d'identification de l'objet - lis-le, et TypeScript bascule lui-même les capteurs sur la bonne variante.

Tu as repéré une erreur dans cette leçon ?

Teste tes connaissances

Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.

  1. 1. De quel type doit être le champ discriminant (ex. kind) dans une union discriminée pour que TypeScript rétrécisse automatiquement le type ?

  2. 2. Qu'est-ce qu'une union discriminée en TypeScript ?

Voici 2 des 3 questions de cette leçon. Réponds aux autres dans le jeu.

Tâches pratiques dans le jeu

  • Tri vertical

    Ordonne les étapes de création et d'utilisation d'une union discriminée dans le bon ordre :

  • Éditeur de code

    Union discriminée pour les variantes de composants

  • Clique dans l'ordre

    Ordonne la syntaxe de la fonction assertNever pour la vérification exhaustive :

  • Tri horizontal

    Ordonne la syntaxe pour définir un type union à partir de trois interfaces :

  • Éditeur de code

    Union discriminée générique pour les états asynchrones

Articles utiles