Vous avez ajouté un état. Vous avez oublié un handler. Rien ne vous a prévenu.
Les state machines commencent propres. Trois valeurs, trois branches switch. Puis la logique de retry ajoute un quatrième état. L’échec partiel ajoute un cinquième. Vous mettez à jour le reducer, mais vous oubliez le composant badge de statut, le mapper analytics et le formateur d’export.
Tout compile. Le bug apparaît deux sprints plus tard quand un utilisateur tombe sur un écran blanc dans un cas limite que vous n’avez jamais testé manuellement.
TypeScript peut rendre cela impossible. Pas avec une règle de linter. Pas avec un test. Avec le système de types lui-même.
Ce que signifie réellement l’exhaustiveness checking
L’exhaustiveness checking, c’est le compilateur qui prouve que vous avez géré chaque variante d’un type. Rust et OCaml le font par défaut. TypeScript le fait aussi, mais vous devez le demander.
L’astuce combine deux choses : les discriminated unions, et un helper qui n’accepte que le type never.
Comment les discriminated unions modélisent les états
Une discriminated union est un type TypeScript où chaque variante possède une propriété littérale partagée, appelée le discriminant. Pour les state machines, c’est presque toujours un champ status, kind ou type.
type State =
| { status: "idle" }
| { status: "loading"; requestId: string }
| { status: "success"; data: string }
| { status: "error"; message: string }
Chaque variante porte exactement les données pertinentes pour cet état. Il n’y a pas de champ optionnel data sur idle, pas de champ message sur success. La forme de l’objet vous dit dans quel état vous êtes, et le compilateur rétrécit automatiquement le type à l’intérieur d’un switch.
function handleState(state: State): string {
switch (state.status) {
case "idle":
return "Waiting..."
case "loading":
return `Loading ${state.requestId}`
case "success":
return state.data
case "error":
return state.message
}
}
À l’intérieur de case "loading", le type est { status: "loading"; requestId: string }, donc state.requestId est disponible. Le compilateur rétrécit automatiquement parce que le littéral status est différent pour chaque variante.
Cela surpasse les string enums et le brouillon de flags booléens. Mais ce n’est pas encore exhaustif. Retirez le cas error et TypeScript compile quand même. La fonction retourne implicitement undefined, et le compilateur reste silencieux.
L’astuce never qui force le traitement exhaustif
Voici le helper :
function assertNever(value: never): never {
throw new Error(`Unhandled value: ${JSON.stringify(value)}`)
}
Cette fonction n’accepte que never, l’union vide, le type sans aucune valeur. Il est impossible d’appeler assertNever avec une valeur réelle à moins d’avoir menti au compilateur.
Maintenant ajoutez-le au bas de votre switch :
function handleState(state: State): string {
switch (state.status) {
case "idle":
return "Waiting..."
case "loading":
return `Loading ${state.requestId}`
case "success":
return state.data
case "error":
return state.message
default:
return assertNever(state)
}
}
Si chaque variante possible de State est gérée au-dessus du default, alors state à l’intérieur de la branche default a été rétréci à never. L’appel à assertNever(state) compile.
Si vous ajoutez un nouvel état et oubliez un cas, la branche default reçoit maintenant un vrai type. Vous ne pouvez pas passer un vrai type à un paramètre never. Le compilateur lève une erreur de type.
Le voir échouer en pratique
Ajoutez un état retrying :
type State =
| { status: "idle" }
| { status: "loading"; requestId: string }
| { status: "success"; data: string }
| { status: "error"; message: string }
| { status: "retrying"; attempt: number; lastError: string }
Maintenant handleState échoue à compiler :
Argument of type '{ status: "retrying"; attempt: number; lastError: string; }'
is not assignable to parameter of type 'never'.
L’erreur pointe directement sur la branche default. Pas un crash à l’exécution. Un refus de compilation à la compilation jusqu’à ce que vous décidiez ce que signifie retrying dans cette fonction spécifique.
Chaque fonction qui switch sur State obtient sa propre erreur. Le compilateur ne vous laisse pas reporter le travail.
Le pattern s’adapte à n’importe quel union type
Ce n’est pas spécifique aux state machines. Toute discriminated union bénéficie du même traitement.
Event handlers :
type Event =
| { type: "USER_LOGIN"; userId: string }
| { type: "USER_LOGOUT" }
| { type: "PAGE_VIEW"; path: string }
function handleEvent(event: Event): void {
switch (event.type) {
case "USER_LOGIN":
trackLogin(event.userId)
return
case "USER_LOGOUT":
trackLogout()
return
case "PAGE_VIEW":
trackPageView(event.path)
return
default:
assertNever(event)
}
}
Reducers style Redux :
type Action =
| { type: "increment"; amount: number }
| { type: "decrement"; amount: number }
| { type: "reset" }
function reducer(state: number, action: Action): number {
switch (action.type) {
case "increment":
return state + action.amount
case "decrement":
return state - action.amount
case "reset":
return 0
default:
return assertNever(action)
}
}
Union d’objets avec un discriminant littéral. Switch dessus. Default vers assertNever. Ajoutez une variante, et chaque switch explose jusqu’à ce que vous la gériez.
Où cela échoue et quoi faire
L’exhaustiveness checking n’est pas magique. Connaissez les limites avant de l’adopter partout.
Vous devez utiliser une discriminated union
Cela ne fonctionne pas avec des string enums simples ou des flags booléens. Le compilateur a besoin d’une propriété littérale partagée pour rétrécir.
// Cela ne fonctionne PAS pour l'exhaustiveness checking
enum Status {
Idle = "idle",
Loading = "loading",
Success = "success",
}
assertNever prouve l’exhaustivité uniquement sur les unions fermées. Avec les enums, le compilateur suppose que toute chaîne correspondante est valide. Il ne peut pas prouver que vous avez manqué un cas.
Vous ne devez pas utiliser any ou des type assertions
Si vous castez via as State ou récupérez depuis une réponse typée any, le compilateur saute le narrowing. Validez aux bords, puis faites confiance aux types à l’intérieur.
assertNever throw à l’exécution en dernier recours
La fonction est d’abord une garde à la compilation, une garde à l’exécution ensuite. En théorie vous ne touchez jamais le throw. En pratique, un cast ou un JSON périmé en fait votre filet de sécurité. C’est mieux que de retourner silencieusement undefined.
Cela devient bruyant avec beaucoup de branches
Ajouter un onzième cas déclenche des erreurs dans chaque fonction qui switch sur le type. C’est le but. Mais si votre union dépasse six ou sept variantes, envisagez de la diviser en sous-unions plus petites ou en machines séparées.
Comment l’introduire sans tout réécrire
Commencez par la state machine qui cause le plus de bugs.
- Trouvez un champ
statustypé commestringou un enum large. Remplacez-le par une union fermée. - Ajoutez
assertNeveraux fonctions qui switch dessus. - Laissez le compilateur vous guider.
- Ajoutez une checklist de code review qui exige
assertNeverdans les nouveaux reducers.
Même avec XState, vous écrirez encore des mappers et des selectors qui switch sur les valeurs d’état. Ces fonctions ont encore besoin d’exhaustiveness checking.
FAQ
Cela fonctionne-t-il avec if/else au lieu de switch ?
Oui, mais c’est fragile. Vous avez besoin d’un else final qui appelle assertNever. Réorganisez les conditions ou ajoutez un early return, et vous pouvez accidentellement supprimer la vérification. switch est plus sûr parce que la branche default est évidente.
Et si je ne me soucie réellement pas de certains états dans une fonction spécifique ?
Gérez-les explicitement. Ne les laissez pas tomber dans assertNever par accident.
case "retrying":
// No-op dans ce contexte
return ""
Le compilateur vous force à rendre la décision visible. C’est le but.
Puis-je retourner une valeur depuis assertNever ?
Une fonction retournant never est assignable à n’importe quel type de retour, parce qu’elle ne retourne jamais réellement. Si vous voulez un fallback au lieu de throw :
function assertNever(value: never, fallback: string): string {
console.error("Unhandled state:", value)
return fallback
}
Le paramètre value: never garde la vérification à la compilation intacte.
Cela fonctionne-t-il en JavaScript ?
Non. C’est une fonctionnalité TypeScript à la compilation. À l’exécution, assertNever est juste une fonction qui throw. La sécurité vient du type checker qui refuse de compiler du code qui pourrait l’atteindre.
Faites du compilateur votre second reviewer
Chaque fois que vous ajoutez un état, vous créez du travail dans le reducer, l’UI, le pipeline analytics, la logique d’export. L’issue par défaut est que vous en manquez un de ces endroits, shippez, et découvrez plus tard.
L’exhaustiveness checking inverse l’issue par défaut. Le compilateur devient un reviewer qui n’oublie jamais de vérifier les switch statements. Il ne remplace pas les tests, mais il attrape une catégorie de bugs que les tests couvrent rarement : celle où vous ne saviez simplement pas que vous deviez écrire du code.
Ajoutez le helper. Utilisez-le dans un reducer. La prochaine fois que vous étendrez une state machine, laissez TypeScript vous dire où est le travail. Il ne manquera pas une branche.