Vous avez refactorisé un type de forme, ajouté une nouvelle variante, et TypeScript est resté vert. Votre CI a passé. Votre déploiement est parti. Puis un utilisateur a atteint une branche à l’exécution qui a retourné undefined, et votre application a planté en production.

Le coupable était presque certainement une instruction switch. Le switch de TypeScript n’offre aucune garantie d’exhaustiveness. Ajoutez un nouveau membre à une union, et chaque switch sur cette union devient silencieusement incomplet. Le compilateur ne vous arrêtera pas. Il ne vous avertira même pas. C’est là que ça coince : TypeScript connaît l’ensemble complet des possibilités, mais les instructions switch ne participent pas à l’exhaustiveness checking.

Ce que signifie réellement l’exhaustiveness checking

L’exhaustiveness signifie que le compilateur peut prouver que chaque valeur possible a été traitée. Des langages comme Rust et OCaml l’imposent à la compilation. TypeScript non, du moins pas pour les instructions switch.

Prenons une discriminated union :

type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "rectangle"; width: number; height: number }
  | { kind: "triangle"; base: number; height: number };

function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "rectangle":
      return shape.width * shape.height;
    // triangle is missing — and TypeScript is fine with it
  }
}

Ça compile. Ça retourne aussi undefined quand shape.kind === "triangle". La signature de la fonction promet un number, mais l’implémentation ne tient pas sa promesse. TypeScript ne signale pas le cas manquant car les blocs switch ne sont pas vérifiés pour leur exhaustiveness.

Le object lookup pattern

Le remplacement le plus simple est une simple object map. Chaque clé mappe vers une fonction handler. Vous indexez dans l’objet avec le discriminant, et le compilateur vérifie que la clé existe.

const areaHandlers: Record<Shape["kind"], (shape: Shape) => number> = {
  circle: (s) => Math.PI * (s as Extract<Shape, { kind: "circle" }>).radius ** 2,
  rectangle: (s) =>
    (s as Extract<Shape, { kind: "rectangle" }>).width *
    (s as Extract<Shape, { kind: "rectangle" }>).height,
  triangle: (s) =>
    0.5 *
    (s as Extract<Shape, { kind: "triangle" }>).base *
    (s as Extract<Shape, { kind: "triangle" }>).height,
};

function area(shape: Shape): number {
  return areaHandlers[shape.kind](shape);
}

C’est mieux. Si vous ajoutez "polygon" à Shape, TypeScript se plaindra que areaHandlers manque une clé. L’erreur pointe au bon endroit, et elle survient à la compilation.

Mais les casts sont moches. On peut faire mieux.

Des handlers type-safe sans casting

L’astuce consiste à mapper chaque variante vers une fonction qui reçoit directement le narrowed type. Vous utilisez un mapped type pour préserver la relation entre la clé et la variante spécifique de la forme.

type ShapeKind = Shape["kind"];

type AreaMap = {
  [K in ShapeKind]: (shape: Extract<Shape, { kind: K }>) => number;
};

const areaHandlers: AreaMap = {
  circle: (s) => Math.PI * s.radius ** 2,
  rectangle: (s) => s.width * s.height,
  triangle: (s) => 0.5 * s.base * s.height,
};

function area(shape: Shape): number {
  const handler = areaHandlers[shape.kind];
  return handler(shape as never);
}

Maintenant chaque handler reçoit un argument correctement narrowed. Pas de casts manuels à l’intérieur des fonctions. Le as never sur la dernière ligne est une petite imperfection, mais elle est localisée et sûre. La vraie valeur est que areaHandlers doit contenir chaque clé de ShapeKind. En retirez une, ou ajoutez une nouvelle variante à Shape, et le build casse.

Exhaustiveness checking avec un assert never

Certaines équipes préfèrent garder le switch mais ajouter une exhaustiveness guard à la compilation. C’est un juste milieu pragmatique. Vous ajoutez une branche default qui prend never, forçant le compilateur à vérifier que tous les cas ont été traités.

function assertNever(x: never): never {
  throw new Error("Unexpected value: " + x);
}

function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "rectangle":
      return shape.width * shape.height;
    case "triangle":
      return 0.5 * shape.base * shape.height;
    default:
      return assertNever(shape);
  }
}

Si vous ajoutez un nouveau type de forme et oubliez d’ajouter un cas, la branche default reçoit une valeur non-never. TypeScript génère une erreur car vous ne pouvez pas assigner un type concret à never. L’erreur pointe sur le switch, ce qui est exactement là où vous la voulez.

Ce pattern est largement utilisé chez Sentry et dans d’autres grandes codebases TypeScript. Il ne nécessite pas de changer la structure de votre code. Il fait juste travailler le compilateur pour vous au lieu de contre vous.

Pattern matching avec des bibliothèques tierces

Si vous voulez quelque chose de plus proche du match de Rust, des bibliothèques comme ts-pattern fournissent du pattern matching avec exhaustiveness checking intégré.

import { match, P } from "ts-pattern";

function area(shape: Shape): number {
  return match(shape)
    .with({ kind: "circle" }, (s) => Math.PI * s.radius ** 2)
    .with({ kind: "rectangle" }, (s) => s.width * s.height)
    .with({ kind: "triangle" }, (s) => 0.5 * s.base * s.height)
    .exhaustive();
}

L’appel .exhaustive() est la clé. Si une variante est manquante, TypeScript rapporte une erreur. La bibliothèque supporte aussi les nested patterns, les guards et les wildcards, qui peuvent remplacer des blocs switch imbriqués complexes.

C’est puissant, mais ça ajoute une dépendance. Pour une seule discriminated union, ts-pattern est overkill. Pour des codebases avec beaucoup de pattern matching, ça vaut le coup du bundle size.

Où les instructions switch gagnent encore

Le switch n’est pas toujours mauvais. Si vos cas doivent faire du fall-through, ou si la logique est impérative avec des early returns et des side effects, un switch peut être plus lisible qu’une lookup table ou une chaîne d’appels .with().

Le problème n’est pas la syntaxe. C’est le manque d’exhaustiveness checking. Si vous utilisez un switch, ajoutez la guard assertNever. C’est deux lignes de code et ça comble le safety gap.

Comment adopter cela sans tout réécrire

Vous n’avez pas besoin de refactoriser tous vos switch aujourd’hui. Voici un ordre d’opérations pratique :

  1. Ajoutez assertNever à vos shared utilities. Utilisez-le dans les nouveaux blocs switch.
  2. Quand vous touchez des switch existants sur des unions, ajoutez la guard à ce moment-là.
  3. Pour le nouveau code sur des discriminated unions, préférez le object-map pattern si la logique est pure et que chaque cas est indépendant.
  4. Évaluez ts-pattern si vous vous retrouvez à écrire des switch imbriqués ou de la logique de matching complexe plus que quelques fois.

Le but n’est pas d’éliminer les instructions switch. C’est de faire en sorte que le compilateur attrape le bug avant vos utilisateurs.

Si vous voulez voir ça en action, essayez de supprimer un handler de l’exemple AreaMap et regardez tsc se plaindre. Ce red squiggle est la différence entre un déploiement tranquille un vendredi et une page PagerDuty le week-end.