Retour au cours

frontend / typescript

Narrowing avancé

Leçon 71 exercice

Explication

Ce que vous allez apprendre

  • Réduire une union de types avec typeof, instanceof et l'opérateur in
  • Utiliser le truthiness narrowing pour éliminer null/undefined d'une valeur optionnelle
  • Mettre en place un garde-fou d'exhaustivité avec never dans le default d'un switch
  • Créer un type guard personnalisé avec la syntaxe x is T
  • Écrire une fonction d'assertion avec asserts x is T pour factoriser une vérification

Dans quel contexte ?

Une union discriminée type Evenement = { type: "clic"; x: number; y: number } | { type: "clavier"; touche: string } est traitée dans un switch (evenement.type). Six mois plus tard, un développeur ajoute une troisième variante { type: "scroll"; delta: number } à l'union, mais oublie de mettre à jour ce switch. Sans garde-fou d'exhaustivité, ce nouveau cas serait silencieusement ignoré à l'exécution. Avec un default: { const _exhaustif: never = evenement; }, TypeScript refuse de compiler tant que le cas "scroll" n'est pas traité, rendant l'oubli impossible à manquer.

Réduire progressivement les possibilités

Quand une valeur peut avoir plusieurs types possibles (une union), TypeScript ne laisse pas utiliser une méthode ou une propriété tant qu'il n'est pas certain qu'elle existe pour toutes les formes possibles. Le narrowing (littéralement rétrécissement) est la technique qui consiste à écrire des tests permettant au compilateur de réduire progressivement cette liste de possibilités jusqu'à n'en garder qu'une seule, avec toutes ses propriétés accessibles en toute sécurité.

Prérequis

Cette leçon prolonge directement la précédente sur les unions et les literal types : sans elle, la notion même de "réduire les possibilités d'une union" n'aurait pas de socle concret.

Plusieurs façons de prouver un type

TypeScript reconnaît plusieurs formes de vérification comme preuves valables : typeof pour les primitives, instanceof pour les instances de classe, l'opérateur in pour tester la présence d'une propriété, ou encore une simple comparaison stricte entre deux valeurs. Chacune de ces vérifications, une fois écrite dans un if, informe le compilateur sur ce qu'il peut désormais garantir à l'intérieur de ce bloc.

Technique de narrowingUtilisée pour
typeof x === "string"Distinguer entre types primitifs
x instanceof MaClasseDistinguer entre instances de classes
"champ" in xDistinguer selon la présence d'une propriété
x.type === "valeur"Union discriminée par un champ commun

Le garde-fou de l'exhaustivité

Un pattern particulièrement puissant consiste à assigner la valeur restante dans le cas default d'un switch au type never. Si, plus tard, un nouveau cas est ajouté à l'union sans être géré dans le switch, cette ligne cesse de compiler — le compilateur avertit immédiatement plutôt que de laisser un bug silencieux filer en production.

Astuce

Ajoutez systématiquement default: { const _exhaustif: never = valeur; throw new Error("cas non géré"); } à la fin d'un switch sur une union discriminée. C'est le meilleur moyen de garantir qu'aucun futur cas ajouté à l'union ne sera oublié silencieusement.

Des gardes réutilisables

Enfin, on peut créer ses propres fonctions de vérification (type guards, avec x is T) ou des fonctions qui lèvent une exception plutôt que de retourner un booléen (asserts x is T), pour factoriser une logique de narrowing utilisée à plusieurs endroits du code.

Commandes & code

Narrowing avancé

ts
// typeof : narrowing sur les primitives
function formatter(valeur: string | number) {
  if (typeof valeur === "string") return valeur.trim();
  return valeur.toFixed(2);
}

// Truthiness narrowing
function afficherNom(nom?: string | null) {
  if (nom) console.log(nom.toUpperCase()); // nom est ici "string" (ni null, ni undefined, ni "")
}

// Egalité stricte entre deux unions : réduit les deux côtés
function comparer(a: string | number, b: string | boolean) {
  if (a === b) {
    // ici a et b sont tous les deux "string" (seul type commun possible)
    a.toUpperCase();
  }
}

// in : narrowing sur la présence d'une propriété
type Chien = { aboie: () => void };
type Chat = { miaule: () => void };
function faireDuBruit(animal: Chien | Chat) {
  if ("aboie" in animal) animal.aboie();
  else animal.miaule();
}

// instanceof : narrowing sur les classes
class ErreurValidation extends Error {
  champ: string;
  constructor(champ: string, message: string) {
    super(message);
    this.champ = champ;
  }
}
function gererErreur(e: unknown) {
  if (e instanceof ErreurValidation) {
    console.log(`Champ invalide : ${e.champ}`);
  } else if (e instanceof Error) {
    console.log(e.message);
  }
}

// Discriminated union + exhaustivité garantie par "never"
type Etat =
  | { statut: "chargement" }
  | { statut: "succes"; donnees: string[] }
  | { statut: "erreur"; message: string };

function afficherEtat(etat: Etat): string {
  switch (etat.statut) {
    case "chargement":
      return "Chargement...";
    case "succes":
      return `Reçu ${etat.donnees.length} éléments`;
    case "erreur":
      return `Erreur : ${etat.message}`;
    default:
      // si un nouveau statut est ajouté sans être géré ci-dessus,
      // cette ligne ne compile plus : garde-fou d'exhaustivité
      const _exhaustif: never = etat;
      throw new Error(`Statut non géré : ${JSON.stringify(_exhaustif)}`);
  }
}

// Type guard personnalisé ("is")
function estChien(animal: Chien | Chat): animal is Chien {
  return "aboie" in animal;
}
function gerer(animal: Chien | Chat) {
  if (estChien(animal)) animal.aboie(); // narrowed via le predicate
}

// asserts : garde qui lève une exception plutôt que de retourner un booléen
function assertEstDefini<T>(valeur: T, msg = "Valeur manquante"): asserts valeur is NonNullable<T> {
  if (valeur === null || valeur === undefined) throw new Error(msg);
}
function utiliser(valeur?: string) {
  assertEstDefini(valeur);
  valeur.toUpperCase(); // valeur est "string" après l'assertion
}

Résumé

  • in, instanceof, typeof et l'égalité stricte réduisent progressivement une union.
  • Un switch (default) assigné à never fait échouer la compilation en cas de cas oublié.
  • Les predicates x is T et asserts x is T créent des gardes réutilisables et typées.

Exercices pratiques

1 disponible
1

Mission : un nouvel événement passé sous silence

Objectif : Ajouter un garde-fou d'exhaustivité à un switch existant, puis diagnostiquer pourquoi un cas manquant serait passé inaperçu sans lui.

Contexte

type Evenement = { type: "clic"; x: number; y: number } | { type: "clavier"; touche: string } est traité par un switch (evenement.type) sans default. Une variante { type: "scroll"; delta: number } vient d'être ajoutée à l'union, mais le switch n'a pas été mis à jour. Rien ne signale l'oubli à la compilation, pour l'instant.

Résoudre l’exercice →