frontend / typescript
Narrowing avancé
Explication
Ce que vous allez apprendre
- Réduire une union de types avec
typeof,instanceofet l'opérateurin - Utiliser le truthiness narrowing pour éliminer
null/undefinedd'une valeur optionnelle - Mettre en place un garde-fou d'exhaustivité avec
neverdans ledefaultd'unswitch - Créer un type guard personnalisé avec la syntaxe
x is T - Écrire une fonction d'assertion avec
asserts x is Tpour 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 narrowing | Utilisée pour |
|---|---|
typeof x === "string" | Distinguer entre types primitifs |
x instanceof MaClasse | Distinguer entre instances de classes |
"champ" in x | Distinguer 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é
// 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,typeofet l'égalité stricte réduisent progressivement une union.- Un
switch (default)assigné àneverfait échouer la compilation en cas de cas oublié. - Les predicates
x is Tetasserts x is Tcréent des gardes réutilisables et typées.
Exercices pratiques
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.