Kodokon kodokon.com

never, exhaustivité et unions discriminées avancées

Vous modélisez vos états avec des unions discriminées et verrouillez leur exhaustivité grâce au type never.

10 min · 3 questions

Ouvrir cette leçon dans Kodokon

never est le type vide : aucune valeur ne l'habite. Il apparaît quand le compilateur prouve qu'un chemin est impossible - après avoir épuisé tous les membres d'une union, par exemple. Cette propriété en fait un outil de conception : si vous parvenez à faire dire never au compilateur dans la branche par défaut d'un switch, vous avez la preuve statique que tous les cas sont traités. C'est le fondement du contrôle d'exhaustivité.

Vous avez découvert les unions discriminées dans le module précédent ; il s'agit maintenant d'en faire un véritable outil de conception. Comparée à une interface unique truffée de champs optionnels, une union discriminée rend les états impossibles irreprésentables : impossible d'avoir à la fois data et error, ou un succès sans données. C'est le pattern de référence pour les états de chargement, les résultats d'opération et les messages d'événements.

TYPESCRIPT
type FetchState<T> =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: T }
  | { status: "error"; error: Error };

function render(state: FetchState<string[]>): string {
  switch (state.status) {
    case "idle": return "En attente";
    case "loading": return "Chargement…";
    case "success": return state.data.join(", ");
    case "error": return state.error.message;
  }
}
data n'existe que dans la variante success : accès impossible ailleurs.

Vous avez déjà croisé la garde assertNever appelée dans la branche default ; décortiquons maintenant sa mécanique exacte. Tant que tous les cas sont couverts, le paramètre y est de type never et tout compile. Ajoutez une variante à l'union sans mettre à jour le switch, et la variante oubliée ne s'assigne plus à never : erreur de compilation, avec le nom du cas manquant dans le message. Vous transformez un bug de production en refus de build.

TYPESCRIPT
type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "square"; side: number }
  | { kind: "rect"; width: number; height: number };

function assertNever(value: never): never {
  throw new Error("Cas non traité : " + String(value));
}

function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle": return Math.PI * shape.radius ** 2;
    case "square": return shape.side ** 2;
    case "rect": return shape.width * shape.height;
    default: return assertNever(shape);
  }
}
Ajouter une variante à Shape casse la compilation de area.

Deux subtilités avancées méritent votre attention. D'abord, le narrowing du discriminant se propage aux structures imbriquées uniquement si vous testez l'objet porteur directement ; déstructurer le discriminant seul casse le lien (sauf cas gérés depuis TypeScript 4.6 pour la déstructuration conjointe). Ensuite, never est absorbant dans les unions (string | never vaut string) : c'est ce qui permet à des types conditionnels de filtrer des variantes, comme le fait Exclude.

TYPESCRIPT
type Event =
  | { type: "click"; x: number; y: number }
  | { type: "keypress"; key: string };

type EventOf<T extends Event["type"]> =
  Extract<Event, { type: T }>;

type ClickEvent = EventOf<"click">;

function handle<T extends Event["type"]>(
  type: T,
  handler: (event: EventOf<T>) => void
): void {
  /* enregistrement du handler */
}

handle("click", (e) => console.log(e.x, e.y));
Extract filtre l'union par discriminant : le handler est précis.

Quiz de validation

Vérifiez que vous avez bien retenu les points clés de cette leçon.

  1. Pourquoi une union discriminée est-elle préférable à une interface avec des champs optionnels pour un état de chargement ?
    • Elle consomme moins de mémoire à l'exécution
    • Elle rend les combinaisons incohérentes (data et error simultanés) irreprésentables
    • Elle évite d'écrire des switch
    • Elle est obligatoire en mode strict
  2. Que se passe-t-il si vous ajoutez une variante à Shape sans modifier le switch contenant assertNever ?
    • Une exception est levée à chaque appel de area
    • Le code compile mais la nouvelle variante renvoie undefined
    • La compilation échoue car la variante oubliée n'est pas assignable à never
  3. Que vaut le type string | never ?
    • never
    • string
    • unknown
    • string | undefined