TypeScript le système de types - 07 - Discriminated unions : le pattern le plus utile de TypeScript

Comment modéliser des états exclusifs avec les discriminated unions. Le pattern qui remplace les booleens multiples et les types optionnels fragiles.

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 never pour 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

Sources

Réservez un audit gratuit de 30 minutes. Je vous montre concrètement ce qu'on peut automatiser.