frontend / typescript
Pattern expert : state machines typées
Explication
Ce que vous allez apprendre
- Modéliser chaque état possible d'un système comme une forme distincte dans une union discriminée
- Comprendre comment un champ discriminant garantit la présence de certains champs dans chaque branche
- Écrire des transitions d'état comme des fonctions pures
(etat, evenement) => nouvelEtat - Écrire un
switchexhaustif qui ne peut oublier aucun cas - Généraliser le pattern avec une table de transitions générique réutilisable
Dans quel contexte ?
Une application de téléchargement de fichiers représente son état avec un seul objet aux champs tous optionnels : { statut: string; progression?: number; url?: string; erreur?: string }. Un bug en production affiche "Téléchargement terminé" avec un lien cassé, parce qu'un setState partiel a mis à jour statut: "succes" sans renseigner url en même temps — rien dans les types n'empêchait cette incohérence. Cette leçon montre comment une union discriminée rend ce genre d'état invalide tout simplement impossible à construire, quelle que soit la façon dont le code évolue par la suite.
Rendre les états invalides impossibles à représenter
Beaucoup de bugs proviennent d'objets qui se retrouvent dans un état incohérent — par exemple un téléchargement marqué réussi mais sans URL de résultat, parce qu'un champ a été oublié lors d'une mise à jour partielle. Une machine à états typée élimine ce problème à la racine : au lieu d'un seul objet avec des champs optionnels un peu partout, on modélise chaque état possible comme une forme distincte et complète, réunies dans une union discriminée.
Le rôle du champ discriminant
Chaque variante de l'union partage un champ commun, ici statut, dont la valeur permet à TypeScript de savoir précisément, à l'intérieur de chaque branche d'un switch, quels autres champs sont garantis présents. Il devient alors impossible d'accéder à la progression dans le cas succès, ou à l'URL dans le cas en cours : le compilateur bloque ces erreurs avant même l'exécution.
État (statut) | Champs garantis présents |
|---|---|
idle | Aucun champ supplémentaire |
en_cours | progression |
succes | url, tailleOctets |
echec | erreur, peutReessayer |
Prérequis
Cette leçon suppose les unions discriminées et le narrowing dans un switch déjà bien acquis : elle les applique ici à un cas d'usage complet, la modélisation d'un cycle de vie métier plutôt qu'un simple type de donnée isolé.
Les transitions comme fonctions pures
Le passage d'un état à un autre est modélisé par une fonction qui prend l'état actuel et un événement, et retourne le nouvel état. Cette approche, héritée du pattern reducer popularisé par Redux, rend les règles métier explicites et testables : on voit clairement, en un seul endroit, quelles transitions sont autorisées depuis quel état.
Bonne pratique
Écrivez votre fonction de transition avec un switch sur le champ discriminant de l'événement, sans clause default. Si vous ajoutez plus tard un nouveau type d'événement et oubliez de gérer ce cas, TypeScript signalera une erreur d'exhaustivité — à condition que le type de retour de la fonction ne permette pas silencieusement undefined.
Généraliser avec une table de transitions
La fin de la leçon montre comment factoriser ce pattern pour n'importe quelle machine à états finis, comme un feu tricolore, grâce à une table de transitions générique, paramétrée par l'ensemble des états et des événements possibles — un prolongement naturel des génériques et des mapped types vus dans les leçons précédentes.
Commandes & code
Pattern expert : state machines typées
// Machine à états représentée par une union discriminée exhaustive :
// impossible de construire un état invalide (ex : "succes" sans URL).
type EtatTelechargement =
| { statut: "idle" }
| { statut: "en_cours"; progression: number }
| { statut: "succes"; url: string; tailleOctets: number }
| { statut: "echec"; erreur: string; peutReessayer: boolean };
// Les transitions sont des fonctions PURES : (etatActuel, evenement) => nouvelEtat
type EvenementTelechargement =
| { type: "DEMARRER" }
| { type: "PROGRESSION"; valeur: number }
| { type: "TERMINE"; url: string; tailleOctets: number }
| { type: "ECHOUE"; erreur: string };
function transition(etat: EtatTelechargement, evenement: EvenementTelechargement): EtatTelechargement {
switch (evenement.type) {
case "DEMARRER":
// transition valide uniquement depuis idle ou echec (règle métier explicite)
if (etat.statut !== "idle" && etat.statut !== "echec") return etat;
return { statut: "en_cours", progression: 0 };
case "PROGRESSION":
if (etat.statut !== "en_cours") return etat;
return { statut: "en_cours", progression: evenement.valeur };
case "TERMINE":
if (etat.statut !== "en_cours") return etat;
return { statut: "succes", url: evenement.url, tailleOctets: evenement.tailleOctets };
case "ECHOUE":
if (etat.statut !== "en_cours") return etat;
return { statut: "echec", erreur: evenement.erreur, peutReessayer: true };
}
}
// Le rendu ne peut PAS oublier un cas : chaque branche accède uniquement
// aux champs garantis présents pour CE statut précis.
function afficher(etat: EtatTelechargement): string {
switch (etat.statut) {
case "idle":
return "En attente de démarrage";
case "en_cours":
return `Téléchargement : ${etat.progression}%`;
case "succes":
return `Terminé : ${etat.url} (${etat.tailleOctets} octets)`;
case "echec":
return etat.peutReessayer ? `Échec, réessai possible : ${etat.erreur}` : `Échec définitif : ${etat.erreur}`;
}
}
// Généralisation : machine à états générique avec table de transitions typées
type Table<S extends string, E extends string> = {
[Etat in S]?: Partial<Record<E, S>>;
};
const tableFeu: Table<"rouge" | "orange" | "vert", "SUIVANT"> = {
rouge: { SUIVANT: "vert" },
vert: { SUIVANT: "orange" },
orange: { SUIVANT: "rouge" },
};
function suivant<S extends string, E extends string>(table: Table<S, E>, etat: S, evenement: E): S {
return table[etat]?.[evenement] ?? etat;
}
let feu: "rouge" | "orange" | "vert" = "rouge";
feu = suivant(tableFeu, feu, "SUIVANT"); // "vert"Résumé
- Une union discriminée rend les états invalides IRREPRÉSENTABLES au niveau des types.
- Chaque branche de
switchne voit que les champs garantis pour ce statut précis. - Une table de transitions générique (
Table<S, E>) factorise le pattern pour n'importe quelle machine à états finis, sans perdre la sécurité de typage.
Exercices pratiques
Mission : réparer un état de téléchargement incohérent
Objectif : Remodéliser un état représenté par des champs optionnels en union discriminée pour rendre un état incohérent impossible à construire.
Contexte
Une application de téléchargement représente son état avec { statut: string; progression?: number; url?: string; erreur?: string }, tous les champs optionnels sauf statut. Un bug en production affiche "Téléchargement terminé" avec un lien cassé : un setState partiel a mis à jour statut: "succes" sans renseigner url. Ta mission : rendre cet état invalide impossible à représenter.