Cours JavaScript et TypeScript · Module 7: TypeScript avancé
Unions discriminées et filtrage par motif (pattern matching)
Dans cette leçon6
« Au centre de contrôle de Jurassic Park, chaque situation requiert une réponse différente », explique Claire Dearing, la directrice des opérations du parc. « Une évasion de dinosaure, une panne de courant, la blessure d'un visiteur - chaque incident a son propre protocole. On ne peut pas en confondre un avec un autre, car les conséquences seraient catastrophiques. Le système doit identifier de lui-même le type d'événement et nous orienter vers la bonne procédure. »
Les unions discriminées sont un motif puissant de TypeScript dans lequel chaque variante d'une union possède un champ commun - un discriminant (étiquette) - qui identifie le type de manière unique. Combinées au filtrage par motif basé sur switch, elles créent un système élégant, sûr et extensible pour gérer différents états et événements.
Les bases des unions discriminées
Une union discriminée est une union de types où chaque type possède un champ portant le même nom (étiquette), mais ayant une valeur littérale différente. TypeScript peut restreindre le type en fonction de la valeur de ce champ.
1// The "status" field is the discriminator
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 narrows the type after checking the status field
28function getDinosaurReport(dino: DinosaurState): string {
29 switch (dino.status) {
30 case "active":
31 // TypeScript knows this is ActiveDinosaur - we have access to lastFed
32 return `${dino.name}: Active, last feeding: ${dino.lastFed.toLocaleString()}`;
33 case "sleeping":
34 // TypeScript knows this is SleepingDinosaur - we have access to estimatedWakeUp
35 return `${dino.name}: Sleeping, estimated wake-up: ${dino.estimatedWakeUp.toLocaleString()}`;
36 case "sick":
37 // TypeScript knows this is SickDinosaur - we have access to diagnosis
38 return `${dino.name}: Sick - ${dino.diagnosis}, veterinarian: ${dino.veterinarianId}`;
39 }
40}Aspects clés des unions discriminées :
- Le champ commun (étiquette/discriminant) doit avoir un type littéral (par ex.
"active", et nonstring) - Chaque variante a une valeur d'étiquette unique
- TypeScript restreint automatiquement le type après avoir vérifié la valeur de l'étiquette
Filtrage par motif avec switch
La construction switch est un outil naturel pour le filtrage par motif en TypeScript. Chaque case correspond à une variante de l'union discriminée.
1// Jurassic Park event system
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 - handling each event type
37function processParkEvent(event: ParkEvent): { action: string; urgency: number } {
38 switch (event.type) {
39 case "feeding":
40 return {
41 action: `Feeding ${event.dinosaurId}: ${event.amountKg}kg ${event.food}`,
42 urgency: 1
43 };
44
45 case "escape":
46 return {
47 action: `ALARM! ${event.dinosaurId} escaped from ${event.enclosureId} (type: ${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: `Maintenance ${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: `Visitors (${event.visitorCount}) in ${event.zone}: ${event.eventKind}`,
62 urgency: visitorUrgency
63 };
64 }
65}Gestion exhaustive - garantir que toutes les variantes sont gérées
L'un des plus grands atouts des unions discriminées est la possibilité d'une gestion exhaustive - le compilateur peut vérifier si tu as géré TOUTES les variantes. Si tu ajoutes un nouveau type à l'union, TypeScript t'indiquera immédiatement tous les endroits à mettre à jour.
1// Helper for exhaustive check
2function assertNever(value: never, message?: string): never {
3 throw new Error(message || `Unhandled variant: ${JSON.stringify(value)}`);
4}
5
6// If we add a new event type to ParkEvent and forget to handle it,
7// TypeScript will report an error in the default clause
8function processEventStrict(event: ParkEvent): string {
9 switch (event.type) {
10 case "feeding":
11 return "Feeding";
12 case "escape":
13 return "Escape";
14 case "maintenance":
15 return "Maintenance";
16 case "visitor":
17 return "Visitors";
18 default:
19 // If a case were missing, TypeScript would report an error:
20 // "Type 'XXX' is not assignable to type 'never'"
21 return assertNever(event);
22 }
23}Machines à états avec unions discriminées
Les unions discriminées sont parfaitement adaptées à la modélisation de machines à états, où un objet peut se trouver dans l'un de plusieurs états, et où les transitions entre états sont strictement contrôlées.
1// Dinosaur enclosure states as a state machine
2interface EnclosureNormal {
3 state: "normall";
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; // Enclosure must be empty during maintenance
27 scheduledEnd: Date;
28 maintenanceType: string;
29}
30
31type EnclosureState = EnclosureNormal | EnclosureLockdown | EnclosureBreach | EnclosureMaintenance;
32
33// Allowed transitions between states
34function transitionEnclosure(
35 current: EnclosureState,
36 action: "lockdown" | "breach" | "repair" | "resolve"
37): EnclosureState {
38 switch (current.state) {
39 case "normall":
40 if (action === "lockdown") {
41 return {
42 state: "lockdown",
43 dinosaurCount: current.dinosaurCount,
44 securityLevel: current.securityLevel + 5,
45 reason: "Security procedure",
46 lockdownSince: new Date()
47 };
48 }
49 if (action === "repair") {
50 if (current.dinosaurCount > 0) {
51 throw new Error("Cannot start maintenance - enclosure occupied!");
52 }
53 return {
54 state: "maintenance",
55 dinosaurCount: 0,
56 scheduledEnd: new Date(Date.now() + 86400000),
57 maintenanceType: "Standard inspection"
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: "Unknown sector",
69 responseTeamDispatched: false
70 };
71 }
72 if (action === "resolve") {
73 return {
74 state: "normall",
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: "normall",
97 dinosaurCount: 0,
98 securityLevel: 5
99 };
100 }
101 return current;
102 }
103}
104
105// State machine test
106let enclosure: EnclosureState = {
107 state: "normall",
108 dinosaurCount: 3,
109 securityLevel: 5
110};
111
112console.log("Initial state:", enclosure.state);
113enclosure = transitionEnclosure(enclosure, "lockdown");
114console.log("After lockdown:", enclosure.state);
115enclosure = transitionEnclosure(enclosure, "breach");
116console.log("After breach:", enclosure.state);
117if (enclosure.state === "breach") {
118 console.log("Escaped dinosaurs:", enclosure.escapedCount);
119}Les réponses d'API comme unions discriminées
Une application courante et concrète consiste à modéliser les réponses d'API à l'aide d'unions discriminées :
1// Generic API response type
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// Dinosaur API data type
23interface DinosaurApiData {
24 id: string;
25 name: string;
26 species: string;
27 weight: number;
28}
29
30// Response handling with full type safety
31function renderDinosaurData(response: ApiResponse<DinosaurApiData>): string {
32 switch (response.status) {
33 case "loading":
34 return `Loading... ${response.progress}%`;
35 case "error":
36 return `Error ${response.errorCode}: ${response.message}`;
37 case "success":
38 const { name, species, weight } = response.data;
39 return `${name} (${species}) - ${weight} kg`;
40 }
41}
42
43// Test responses
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: "Dinosaur not found",
54 timestamp: new Date()
55};
56
57console.log(renderDinosaurData(loading));
58console.log(renderDinosaurData(success));
59console.log(renderDinosaurData(error));Résumé
Les unions discriminées et le filtrage par motif comptent parmi les motifs les plus puissants de TypeScript :
- Unions discriminées - chaque variante possède un champ commun (étiquette) avec une valeur littérale unique
- Filtrage par motif avec switch - gestion élégante de toutes les variantes
- Gestion exhaustive avec never - le compilateur garantit que tu as géré chaque variante
- Machines à états - modélisation des transitions autorisées entre états
- Réponses d'API - typer succès/erreur/chargement en toute sécurité
Comme le dit Claire Dearing : « À Jurassic Park, chaque scénario a son protocole. Grâce aux unions discriminées, TypeScript garantit qu'aucun scénario n'est oublié. C'est comme un système d'alerte précoce - il ne laisse passer aucune menace. »
Code de cette leçon : index.ts
1// Discriminated unions et pattern matching - Parc Jurassique
2console.log("=== DISCRIMINATED UNIONS ===\n");
3
4// ===========================================
5// 1. Union discriminée de base
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}: actif, nourri à ${dino.lastFed}`;
31 case "sleeping": return `${dino.name}: dort, se réveille à ${dino.wakeUpAt}`;
32 case "sick": return `${dino.name}: malade - ${dino.diagnosis}`;
33 }
34}
35
36console.log("--- État des dinosaures ---");
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: "Fièvre" }));
40
41// ===========================================
42// 2. Système d'événements avec filtrage par motif
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: `Alimentation ${event.dinoId}: ${event.kg}kg ${event.food}`, urgency: 1 };
55 case "escape":
56 return { action: `ALARM! ${event.dinoId} s'est échappé de ${event.zone}!`, urgency: 10 };
57 case "maintenance":
58 return { action: `Maintenance: ${event.system}`, urgency: event.priority === "high" ? 7 : 3 };
59 }
60}
61
62console.log("\n--- Événements ---");
63const events: ParkEvent[] = [
64 { type: "feed", dinoId: "D-01", food: "Chèvre", kg: 50 },
65 { type: "escape", dinoId: "D-03", zone: "Secteur B" },
66 { type: "maintenance", system: "Fence A", priority: "high" },
67];
68events.forEach(e => {
69 const r = processEvent(e);
70 console.log(`[${r.urgency}] ${r.action}`);
71});
72
73// ===========================================
74// 3. Vérification exhaustive avec never
75// ===========================================
76
77function assertNever(value: never, msg?: string): never {
78 throw new Error(msg || "Variante non gérée: " + JSON.stringify(value));
79}
80
81function strictProcess(event: ParkEvent): string {
82 switch (event.type) {
83 case "feed": return "Alimentation";
84 case "escape": return "Évasion";
85 case "maintenance": return "Maintenance";
86 default: return assertNever(event);
87 }
88}
89
90console.log("\n--- Exhaustive check ---");
91console.log("Toutes les variantes gérées:", strictProcess(events[0]));
92
93// ===========================================
94// 4. Machine à états des enclos
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: "Procédure" };
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--- Machine à états ---");
119let enc: EncState = { state: "normal", dinoCount: 3 };
120console.log("Start:", enc.state);
121enc = transition(enc, "lock");
122console.log("Après lock:", enc.state);
123enc = transition(enc, "breach");
124console.log("Après breach:", enc.state);
125enc = transition(enc, "resolve");
126console.log("Après resolve:", enc.state);
127
128// ===========================================
129// 5. Motif de réponse d'API
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 `Chargement... ${res.progress}%`;
141 case "error": return `Erreur ${res.code}: ${res.message}`;
142 case "ok": return `Données: ${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: "Non trouvé" }));
150
151console.log("\nDiscriminated unions = pattern matching sécurisé!");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. Qu'est-ce qu'une union discriminée en TypeScript ?
2. À quoi sert le type never dans la clause default d'une instruction switch lors de la gestion d'une union discriminée ?
Voici 2 des 4 questions de cette leçon. Réponds aux autres dans le jeu.
Tâches pratiques dans le jeu
- Tri horizontal
Range les éléments d'un type union discriminée :
- Éditeur de code
Crée une union discriminée avec filtrage par motif et vérification exhaustive pour les événements du parc
- Tri horizontal
Range les éléments de l'utilisation de Omit :
- Tri horizontal
Range les éléments de l'utilisation de Record :
- Éditeur de code
Crée un système de types complet pour la gestion du parc
- Tri horizontal
Range les éléments de l'utilisation de Required :
- Tri horizontal
Range les éléments du type Pick :
- Tri vertical
Ordonne l'évolution d'un type simple vers un type utilitaire avancé :