07 - Discriminated unions : le pattern le plus utile de TypeScript
Ce que tu vas apprendre
- Ce qu'est une discriminated union et comment elle fonctionne
- Pourquoi c'est supérieur aux booleens multiples et aux champs optionnels
- Comment le compilateur narrow le type automatiquement dans un switch
- Des patterns concrets : états d'une requête, formulaires, notifications
Prerequisites
Avoir lu les articles sur les generics et le narrowing.
Le formulaire aux 8 booleens
Un dev junior m'a montre un composant React pour un formulaire de commande. Le state ressemblait a ca :
typescriptinterface OrderState {
isLoading: boolean
isError: boolean
isSuccess: boolean
isPending: boolean
isRefunded: boolean
isCancelled: boolean
error: string | null
data: Order | null
}
Le composant avait des conditions partout : if (isLoading && !isError && !isSuccess), if (isError && !isLoading), etc. Et il y avait des états impossibles que le type autorisait : isLoading: true, isSuccess: true, isError: true en meme temps. Le dev passait plus de temps a gerer les combinaisons de booleens qu'a afficher les donnees.
Le problème : le type ne represente pas la réalité. Une commande ne peut pas etre a la fois en chargement et en erreur et en succes. Ces états sont mutuellement exclusifs. Mais le type ne l'exprime pas.
Discriminated unions : des états exclusifs
Une discriminated union est un type union ou chaque variante a une propriété commune (le discriminant) qui identifié la variante.
typescripttype OrderState =
| { status: "idle" }
| { status: "loading" }
| { status: "error"; error: string }
| { status: "success"; data: Order }
| { status: "refunded"; data: Order; refundDate: Date }
| { status: "cancelled"; reason: string }
Le discriminant ici, c'est status. Chaque variante a un status avec une valeur literale différente. Et chaque variante porte uniquement les donnees qui ont du sens pour cet état.
Pas de data: null quand on est en chargement. Pas de error: null quand on a les donnees. Le type interdit les états impossibles.
Le narrowing automatique
Quand tu fais un switch ou un if sur le discriminant, TypeScript narrow le type automatiquement :
typescriptfunction render(state: OrderState) {
switch (state.status) {
case "idle":
return <p>Pret a commander</p>
case "loading":
return <Spinner />
case "error":
// TypeScript sait que state a un champ error ici
return <p>Erreur : {state.error}</p>
case "success":
// TypeScript sait que state a un champ data ici
return <OrderDetails order={state.data} />
case "refunded":
// state.data ET state.refundDate sont disponibles
return <RefundReceipt order={state.data} date={state.refundDate} />
case "cancelled":
return <p>Annulee : {state.reason}</p>
}
}
Dans chaque case, le type est narrow a la variante correspondante. Le compilateur sait quelles propriétés existent. Pas besoin de if (state.data) ou de state.data!. Le type garantit la presence des donnees.
Le pattern exhaustive check
Combine avec never (qu'on a vu dans l'article 01), tu peux vérifier que tu geres tous les cas :
typescriptfunction render(state: OrderState) {
switch (state.status) {
case "idle":
return <p>Pret</p>
case "loading":
return <Spinner />
case "error":
return <p>{state.error}</p>
case "success":
return <OrderDetails order={state.data} />
// oubli de "refunded" et "cancelled"
default: {
const _exhaustive: never = state
// ❌ Type '{ status: "refunded"; ... }' is not assignable to type 'never'
throw new Error(`Etat non gere : ${_exhaustive}`)
}
}
}
Si tu ajoutes un nouveau statut a l'union sans ajouter le case, le compilateur refuse. C'est un filet de sécurité qui s'active a la compilation.
Discriminated unions vs interfaces avec optionnels
Comparons les deux approches pour un résultat d'API :
typescript// ❌ Avec des optionnels — etats impossibles autorises
interface ApiResult {
loading: boolean
error?: string
data?: User
}
// Rien n'empeche : { loading: true, error: "fail", data: someUser }
typescript// ✅ Avec une discriminated union — etats exclusifs
type ApiResult =
| { status: "loading" }
| { status: "error"; error: string }
| { status: "success"; data: User }
La deuxieme version est plus longue a écrire. Mais elle elimine toute une categorie de bugs. Pas de data quand c'est en erreur. Pas de error quand c'est en succes. Le type represente exactement les états possibles, rien de plus.
Pattern : les événements
Les discriminated unions sont parfaites pour modéliser des événements :
typescripttype AppEvent =
| { type: "USER_LOGIN"; userId: string; timestamp: Date }
| { type: "USER_LOGOUT"; userId: string }
| { type: "PAGE_VIEW"; path: string; referrer: string | null }
| { type: "PURCHASE"; orderId: string; amount: number; currency: string }
function track(event: AppEvent) {
switch (event.type) {
case "USER_LOGIN":
analytics.identify(event.userId)
break
case "PURCHASE":
analytics.revenue(event.amount, event.currency)
break
// ...
}
}
Chaque événement a ses propres donnees. Le discriminant type permet au compilateur de savoir quelles propriétés existent. Ce pattern est utilise par Redux, XState, et la plupart des systèmes event-driven en TypeScript.
Pattern : les résultats d'opérations
Au lieu de lancer des exceptions, retourne une discriminated union :
typescripttype Result<T> =
| { ok: true; value: T }
| { ok: false; error: string }
function parseAge(input: string): Result<number> {
const n = parseInt(input, 10)
if (isNaN(n)) {
return { ok: false, error: `"${input}" n'est pas un nombre` }
}
if (n < 0 || n > 150) {
return { ok: false, error: `Age ${n} hors limites` }
}
return { ok: true, value: n }
}
const result = parseAge("abc")
if (result.ok) {
console.log(result.value) // type: number ✅
} else {
console.log(result.error) // type: string ✅
}
C'est le pattern Result, inspire de Rust. L'appelant est force de gerer les deux cas. L'article sur l'error handling type dans la sous-serie pratique approfondira ce pattern.
Pattern : les notifications
Sur paltemps.fr, les notifications sont modelisees comme ca :
typescripttype Notification =
| {
channel: "email"
to: string
subject: string
body: string
}
| {
channel: "sms"
phoneNumber: string
message: string
}
| {
channel: "push"
deviceToken: string
title: string
body: string
}
function send(notification: Notification) {
switch (notification.channel) {
case "email":
mailer.send(notification.to, notification.subject, notification.body)
break
case "sms":
smsProvider.send(notification.phoneNumber, notification.message)
break
case "push":
pushService.send(notification.deviceToken, notification.title, notification.body)
break
}
}
Quand j'ai ajoute le canal "push" six mois apres le lancement, le compilateur m'a montre les 4 endroits du code ou je devais gerer ce nouveau canal. Sans la discriminated union, j'aurais du chercher a la main dans le code et j'en aurais probablement oublie.
Quand utiliser une discriminated union
- États mutuellement exclusifs (loading/error/success)
- Événements avec des payloads différents
- Messages de différents types dans un système
- Résultats d'opérations (succes/échec)
- Tout ce qui ressemble a un "ou exclusif" entre plusieurs formes
Quand ne pas l'utiliser :
- Si les états ne sont pas exclusifs (un utilisateur peut etre admin ET moderateur)
- Si tu as un seul état avec des champs optionnels independants
Résumé
- Une discriminated union est un type union avec un champ discriminant commun a toutes les variantes
- Le compilateur narrow automatiquement le type dans les blocs switch/if sur le discriminant
- Les discriminated unions interdisent les états impossibles que les booleens multiples et les optionnels autorisent
- Combine avec
neverpour vérifier l'exhaustivite a la compilation - Patterns courants : états de requête, événements, résultats d'opérations, messages types
Article précédent : 06 - Generics
Article suivant : 08 - Type guards : is, asserts, instanceof, in