Menu

Unions discriminées en TypeScript : les tagged unions expliquées

Une union discriminée est une union de types objet qui partagent une propriété étiquette littérale, comme kind ou status. Vérifier l'étiquette affine tout l'objet. Découvrez le motif, le narrowing avec switch, les vérifications exhaustives avec never, et comment modéliser des résultats d'API, l'état d'une requête et des machines à états.

Cette page contient des éditeurs exécutables - modifiez, exécutez et voyez la sortie instantanément.

Une union discriminée (aussi appelée tagged union) est une union de types objet qui ont tous une propriété en commun, l'étiquette, avec une valeur littérale différente dans chaque membre. Vérifier l'étiquette indique à TypeScript quel membre vous avez, et les autres propriétés de ce membre deviennent disponibles.

Dans case "circle", lire shape.width serait une erreur de compilation, car le membre cercle n'a pas de width. L'étiquette est une donnée ordinaire : à l'exécution, c'est juste une propriété de type chaîne, et le switch est du JavaScript pur.

Ce qui rend une union discriminée

Le narrowing sur l'étiquette fonctionne quand trois conditions sont réunies :

  1. Chaque membre est un type objet.
  2. Chaque membre a la même propriété (le discriminant). Le nom est à votre choix : kind, type, status et tag sont courants.
  3. Dans chaque membre, cette propriété a un type littéral : un littéral de chaîne, de nombre ou de booléen, ou null/undefined.
type UiEvent =
  | { type: "click"; x: number; y: number }
  | { type: "keypress"; key: string }
  | { type: "scroll"; delta: number };

type ApiResponse =
  | { ok: true; data: string }    // boolean literal tags
  | { ok: false; error: string };

type Message =
  | { version: 1; text: string }  // number literal tags
  | { version: 2; text: string; lang: string };

Si un membre déclare l'étiquette comme simple string au lieu d'un littéral, comparer l'étiquette n'affine plus l'union, et les propriétés propres à chaque membre restent hors de portée (TS2339).

Affiner sur l'étiquette

Toute vérification sur l'étiquette que TypeScript comprend affine l'objet : switch, if/else, === et !==, et même une étiquette déstructurée tant qu'elle est const :

function describe(e: UiEvent): string {
  if (e.type === "click") return `click at ${e.x},${e.y}`;

  const { type } = e;        // destructured tags narrow too
  if (type === "keypress") return `key ${e.key}`;

  return `scroll by ${e.delta}`; // only "scroll" is left
}

Vérifier la présence d'une propriété avec "radius" in shape affine aussi, mais comparer une étiquette se lit plus clairement et rend possible la vérification exhaustive.

Vérifications exhaustives

Le plus grand avantage du motif apparaît quand l'union grandit. Avec un type de retour explicite et sans default, TypeScript sait que le switch doit couvrir chaque étiquette : un nouveau membre sans cas est donc une erreur de compilation.

index.ts(7,30): error TS2366: Function lacks ending return statement and return type does not include 'undefined'.

Ce message ne dit pas quel cas manque. Le helper assertNever le dit, et il lève aussi une exception à l'exécution si des données extérieures (JSON, un client plus ancien) portent une étiquette que les types déclarent impossible :

Ajoutez un cinquième statut sans cas et la ligne assertNever(state) signale Argument of type '{ status: "..."; ... }' is not assignable to parameter of type 'never', en nommant le membre. Plus de détails sur la page never.

Rendre impossibles les états impossibles

Le RequestState ci-dessus remplace une forme courante et plus faible :

// Every combination is allowed, including nonsense
type LooseState<T> = {
  loading: boolean;
  data?: T;
  error?: string;
};

const nonsense: LooseState<string[]> = { loading: true, data: ["a"], error: "timeout" };

Avec des champs optionnels, « en chargement avec des données et une erreur » passe la vérification des types, et chaque lecteur doit deviner quelles combinaisons peuvent réellement se produire. Avec l'union discriminée, data n'existe que dans l'état success et error que dans error : le code qui lit state.data doit donc d'abord prouver qu'il est dans l'état success. Le type documente lui-même les états valides.

Modéliser des résultats : succès ou échec

Une étiquette booléenne suffit pour « ça a marché ou pas ». Ce type Result est une alternative courante aux exceptions pour les échecs attendus, comme une entrée invalide :

L'appelant ne peut pas lire result.value sans vérifier d'abord result.ok, et c'est exactement la vérification qu'on oublie facilement avec les exceptions ou les retours null.

Machines à états et reducers

Deux unions discriminées, l'une pour les états et l'autre pour les actions, décrivent une machine à états. Un reducer fait un switch sur l'action et renvoie l'état suivant ; le compilateur vérifie que chaque objet renvoyé est un état valide :

{ ...state, name: "paused" } ne compile que parce que state a d'abord été affiné vers le membre de lecture, si bien que la copie emporte track et position. Renvoyer { name: "paused" } seul serait une erreur : un état en pause a besoin d'un morceau. C'est la même forme qu'utilisent les reducers Redux et useReducer dans React.

Le piège de l'élargissement

L'étiquette doit rester un type littéral. Un objet construit dans une variable sans annotation voit son étiquette élargie en string, et il ne correspond alors plus à aucun membre :

type Shape = { kind: "circle"; radius: number } | { kind: "rect"; width: number; height: number };
declare function area(shape: Shape): number;

const c = { kind: "circle", radius: 2 }; // kind: string
area(c);
// error TS2345: Argument of type '{ kind: string; radius: number; }' is not assignable to parameter of type 'Shape'.

const ok1: Shape = { kind: "circle", radius: 2 };        // annotate the variable
const ok2 = { kind: "circle", radius: 2 } as const;      // or keep the literal with as const
area({ kind: "circle", radius: 2 });                     // or build it where Shape is expected

La règle sous-jacente, selon laquelle les propriétés modifiables s'élargissent, est expliquée sur la page types littéraux.

Questions fréquentes

Qu'est-ce qu'une union discriminée en TypeScript ?

Une union de types objet où chaque membre possède la même propriété (le discriminant, ou étiquette) avec un type littéral différent, par exemple { kind: "circle"; radius: number } | { kind: "rect"; width: number; height: number }. Vérifier shape.kind === "circle" affine shape vers le membre cercle, et ses autres propriétés deviennent disponibles.

Quelle est la différence entre une union et une union discriminée ?

Une union discriminée est une union avec une règle de plus : tous les membres partagent une propriété dont le type littéral est unique. Une union simple comme Cat | Fish doit être affinée avec in ou des type guards personnalisés ; une union discriminée s'affine en comparant une seule propriété, et un switch sur cette propriété peut être vérifié pour l'exhaustivité.

Comment rendre exhaustif un switch sur une union discriminée ?

Soit vous donnez à la fonction un type de retour explicite sans default (un cas manquant provoque alors l'erreur TS2366), soit vous ajoutez default: return assertNever(value) avec function assertNever(x: never): never { throw ... }. La seconde forme nomme le membre non traité dans l'erreur, et lève aussi une exception à l'exécution si des données inattendues arrivent.

Pourquoi mon union discriminée ne s'affine-t-elle pas ?

L'étiquette doit être un type littéral dans chaque membre. Si un objet a été créé sans annotation, son kind est élargi en string et il ne correspond plus à aucun membre (TS2345/TS2322). Corrigez avec une annotation, as const, ou en créant l'objet là où le type union est attendu. Un membre typé kind: string empêche aussi l'union de s'affiner sur kind.

Le discriminant peut-il être un booléen ou un nombre ?

Oui. N'importe quel type littéral convient : chaînes (kind: "circle"), nombres (version: 2), booléens (ok: true / ok: false), et même null ou undefined. Les étiquettes de type chaîne sont les plus courantes, car elles restent lisibles dans les logs ou en JSON.

Coddy programming languages illustration

Apprendre à coder avec Coddy

COMMENCER