Leçon 4/10 11 min

Unions et narrowing

Une valeur qui peut être deux choses : string | number. Le narrowing rétrécit l'incertitude jusqu'au type sûr.

La vraie vie est incertaine

Un champ de formulaire renvoie toujours une chaîne, ou rien. Un prix arrive en number depuis une API, mais en string depuis une autre (c'est du vécu). En JavaScript, on croise les doigts et on espère que la valeur est bien du bon type au moment où on l'utilise. Quand ça plante, c'est au runtime, sur la machine d'un utilisateur, jamais en dev.

En TypeScript, tu n'as pas à choisir entre mentir et laisser en any : tu écris l'incertitude. number | string dit exactement ce que c'est, l'un ou l'autre, et le compilateur t'oblige à résoudre cette incertitude avant d'agir dessus.

let prix: number | string;

prix = 9.99;       // ✓ number
prix = '9,99 €';   // ✓ string
prix = true;       // ✗ refusé : boolean n'est pas dans l'union

L'union A | B ne veut pas dire que la valeur a les deux types en même temps : elle est l'un ou l'autre selon le chemin d'exécution. Tant que tu ne sais pas lequel, tu n'as droit qu'aux opérations communes aux deux ; le compilateur t'empêche d'utiliser ce qui pourrait ne pas exister.

Prédis avant de lire

prix est typé number | string et on appelle prix.toFixed(2). Pourquoi TypeScript refuse-t-il, alors que ça marcherait « souvent » ?

Voir la réponse

toFixed n'existe pas sur string, et « souvent » ne suffit pas à un contrat. TypeScript n'autorise que ce qui marche dans tous les cas de l'union : tant que tu n'as pas prouvé (via le narrowing) de quel côté tu es, la méthode est interdite. Ce n'est pas de la méfiance gratuite : c'est exactement le crash qu'il t'évite au runtime.

Narrowing : prouver au compilateur de quel côté tu es

L'union écrit l'incertitude. Le narrowing la résout. Concrètement : tu fournis une preuve (une garde de type) et dans le bloc qui suit, TypeScript sait exactement ce que tu as :

function afficherPrix(prix: number | string): string {
  if (typeof prix === 'number') {
    // Ici : TypeScript sait que prix est un number
    return prix.toFixed(2) + ' €';   // ✓
  }
  // Ici : TypeScript sait que prix est forcément un string
  return prix + ' €';                // ✓
}

Le typeof prix === 'number' n'est pas du sucre syntaxique : c'est du vrai JavaScript qui sera dans le bundle final, et TypeScript le lit comme une preuve. Dans le bloc if, le type de prix est réellement number ; après, il est réellement string. Le compilateur suit ta preuve bloc par bloc.

Autres gardes courantes :

  • instanceof pour les classes : if (err instanceof TypeError)
  • Vérifier une propriété : if ('erreur' in reponse)
  • Comparaison à un littéral : if (statut === 'brouillon')

Les unions de littéraux : type Statut = 'brouillon' | 'publie' | 'archive' combine union et valeurs exactes. Toute autre chaîne (faute de frappe, valeur hors contrat) est refusée à la compilation. Pour les ensembles de valeurs simples, c'est souvent plus lisible qu'un enum, sans la magie de compilation derrière.

À toi : fais taire le compilateur

Le code ci-dessous appelle prix.toFixed(2) sans savoir si prix est un number ou un string. Clique sur Vérifier les types : le compilateur va signaler l'erreur. Lis le message, applique le narrowing avec un if (typeof prix === 'number'), puis revérifie jusqu'au ✓ 0 erreur. Tu peux aussi cliquer sur Exécuter pour voir les deux sorties.

🧐 Labo TypeScript · le compilateur juge ton code (mode strict)
Bloqué sur la correction ? Voir le fix

Remplace le corps de la fonction par :

if (typeof prix === 'number') {
  return prix.toFixed(2) + ' €';
}
return prix + ' €';

Dans le bloc if, TypeScript SAIT que prix est number : toFixed est autorisé. Après le bloc, il SAIT que c'est forcément string : la concaténation suffit. Revérifie : ✓ 0 erreur. Exécute : 10.00 € puis 10,50 €.

Le raccourci trompeur : face à l'erreur, l'IA peut proposer (prix as number).toFixed(2) (« c'est plus court »). C'est un cast, pas un narrowing. Il fait taire le compilateur sans résoudre le problème : si prix est vraiment un string au runtime, toFixed n'existe pas et tu as un TypeError en prod. Le if typeof, lui, existe vraiment dans le JavaScript exécuté.

Le pattern roi : l'union discriminée

Avec les APIs, tu as souvent deux formes possibles de réponse : succès ou erreur. L'union discriminée modélise ça proprement, et le narrowing est automatique :

type Reponse =
  | { ok: true;  data: string }
  | { ok: false; erreur: string };

function traiter(r: Reponse) {
  if (r.ok) {
    console.log(r.data);    // ✓ TS sait que r est { ok: true, data: string }
  } else {
    console.log(r.erreur);  // ✓ TS sait que r est { ok: false, erreur: string }
    // console.log(r.data); // ✗ refusé : data n'existe pas côté erreur
  }
}

La propriété discriminante (ok) est le pivot : TypeScript la lit dans le if et rétrécit chaque branche automatiquement. Tu ne peux pas lire data côté erreur, non pas parce qu'on t'en a empêché manuellement, mais parce que le type de r dans ce bloc ne l'a pas. C'est la vérification structurelle à son meilleur.

Union discriminée pour les APIs. Modélise toujours le succès et l'erreur comme deux formes distinctes avec un champ commun (ok, type, status). L'IA génère souvent data?: string; erreur?: string (tout optionnel), ce qui te laisse lire data côté erreur sans erreur de compilation. L'union discriminée élimine ça structurellement.

Entonnoir du narrowing : en haut, un flux number | string mélangé ; une étape typeof === 'number' sépare les deux ; à gauche, bloc vert « number → .toFixed ✓ » ; à droite, bloc « string → concat ✓ ». Flux entrant number | string (incertain) garde : typeof prix === 'number' TypeScript lit la preuve true → number false → string ici : number .toFixed(2) ✓ ici : string concat ✓
L'entonnoir du narrowing : la garde typeof divise le flux en deux branches propres.

Real life is uncertain

A form field always returns a string — or nothing. A price comes in as a number from one API, but as a string from another (seen it happen). In JavaScript, you cross your fingers and hope the value is the right type when you use it. When it crashes, it's at runtime, on a user's machine, never in dev.

In TypeScript, you don't have to choose between lying and leaving it as any: you write the uncertainty. number | string says exactly what it is — one or the other — and the compiler forces you to resolve that uncertainty before acting on it.

let price: number | string;

price = 9.99;       // ✓ number
price = '£9.99';    // ✓ string
price = true;       // ✗ rejected: boolean is not in the union

The union A | B does not mean the value has both types at the same time: it's one or the other depending on the execution path. Until you know which, you only have access to operations common to both — the compiler stops you from using anything that might not exist.

Predict before reading on

price is typed number | string and you call price.toFixed(2). Why does TypeScript refuse, even though it would work "most of the time"?

Show the answer

toFixed doesn't exist on string — and "most of the time" isn't enough for a contract. TypeScript only permits what works in every case of the union: until you have proved (via narrowing) which side you're on, the method is off-limits. That's not pointless caution — it's exactly the runtime crash it saves you from.

Narrowing: proving to the compiler which side you're on

The union writes the uncertainty. Narrowing resolves it. Concretely: you supply a proof — a type guard — and in the block that follows, TypeScript knows exactly what you have:

function formatPrice(price: number | string): string {
  if (typeof price === 'number') {
    // Here: TypeScript knows price is a number
    return price.toFixed(2) + ' $';   // ✓
  }
  // Here: TypeScript knows price must be a string
  return price + ' $';                // ✓
}

The typeof price === 'number' isn't syntactic sugar: it's real JavaScript that will be in the final bundle, and TypeScript reads it as proof. Inside the if block, the type of price is genuinely number; afterwards, it's genuinely string. The compiler follows your proof block by block.

Other common guards:

  • instanceof for classes: if (err instanceof TypeError)
  • Checking a property: if ('error' in response)
  • Comparing to a literal: if (status === 'draft')

Literal unions: type Status = 'draft' | 'published' | 'archived' combines union and exact values. Any other string (typo, out-of-contract value) is rejected at compilation. For simple sets of values, this is often more readable than an enum — with no compilation magic behind it.

Your turn: silence the compiler

The code below calls price.toFixed(2) without knowing whether price is a number or a string. Click Check the types: the compiler will flag the error. Read the message, apply narrowing with if (typeof price === 'number'), then re-check until ✓ 0 errors. You can also click Run to see both outputs.

🧐 TypeScript lab · the compiler judges your code (strict mode)
Stuck on the fix? Show it

Replace the function body with:

if (typeof price === 'number') {
  return price.toFixed(2) + ' $';
}
return price + ' $';

Inside the if block, TypeScript KNOWS that price is a numbertoFixed is allowed. After the block, it KNOWS it must be a string — concatenation is enough. Re-check: ✓ 0 errors. Run: 10.00 $ then 10.50 $.

Beware the tempting shortcut: faced with the error, an AI may suggest (price as number).toFixed(2) — "it's shorter". That's a cast, not narrowing. It silences the compiler without fixing the problem: if price is genuinely a string at runtime, toFixed doesn't exist and you get a TypeError in prod. The if typeof, on the other hand, actually exists in the executed JavaScript.

The king pattern: discriminated unions

With APIs, you often have two possible response shapes: success or error. A discriminated union models this cleanly — and narrowing is automatic:

type Response =
  | { ok: true;  data: string }
  | { ok: false; error: string };

function handle(r: Response) {
  if (r.ok) {
    console.log(r.data);    // ✓ TS knows r is { ok: true, data: string }
  } else {
    console.log(r.error);   // ✓ TS knows r is { ok: false, error: string }
    // console.log(r.data); // ✗ rejected: data doesn't exist on the error side
  }
}

The discriminant property (ok) is the pivot: TypeScript reads it in the if and narrows each branch automatically. You can't read data on the error side — not because it was manually blocked, but because the type of r in that block simply doesn't have it. That's structural checking at its best.

Discriminated unions for APIs: always model success and error as two distinct shapes with a shared field (ok, type, status). AI often generates data?: string; error?: string — everything optional — which lets you read data on the error side without a compile error. The discriminated union eliminates this structurally.

Narrowing funnel: at the top, a mixed number | string stream; a typeof === 'number' step splits the two; on the left, green block "number → .toFixed ✓"; on the right, blue block "string → concat ✓". Incoming stream number | string (uncertain) guard: typeof price === 'number' TypeScript reads the proof true → number false → string here: number .toFixed(2) ✓ here: string concat ✓
The narrowing funnel: the typeof guard splits the stream into two clean branches.

🎯 Pratique

S'entraîner (clique pour ouvrir) :

💬 Ré-explique sans regarder
Ré-explique sans regarder

Explique le duo union/narrowing avec l'exemple du prix. Couvre : ce qu'écrit l'union, ce que TS autorise tant qu'il ne sait pas, ce que fait la garde typeof, et comment TS suit ta preuve.

Une bonne explication dit : l'union number | string écrit l'incertitude : la valeur est l'un OU l'autre ; TS n'autorise que les opérations communes aux deux tant qu'on ne sait pas lequel ; le typeof prix === 'number' est une preuve que TS lit dans le flux d'exécution ; dans le bloc if, prix EST un number ; après, prix EST forcément un string : TS suit la preuve bloc par bloc.
🧠 Rappel libre
Rappel libre

De mémoire : à quoi ressemble une réponse d'API en union discriminée, et qu'apporte le if (reponse.ok) ?

{ ok: true; data: string } | { ok: false; erreur: string }. Le if (reponse.ok) rétrécit chaque branche automatiquement : côté true, TS sait que data existe ; côté false, lire data est une erreur de compilation : impossible d'oublier de gérer le cas erreur.
⚖️ Juge le code de l'IA
Accepter ou rejeter le code de l'IA

Pour faire passer prix.toFixed(2), l'IA propose (prix as number).toFixed(2) (« plus court qu'un if »). Tu acceptes, ou tu rejettes ?

À rejeter. Le cast as number ment au compilateur : il dit « fais confiance, c'est un number », mais si prix est réellement un string au runtime, toFixed n'existe pas et c'est un TypeError en prod. Le if (typeof prix === 'number') est la preuve réelle : deux lignes qui existent vraiment dans le JavaScript exécuté, qui résolvent le problème sans mentir. La règle d'or : narrower (prouver) > caster (affirmer sans preuve).
Que veut dire prix: number | string ?
Que fait if (typeof prix === 'number') pour TypeScript ?
Pourquoi type Statut = 'brouillon' | 'publie' est-il utile ?
Face à number | string, quelle est la différence entre narrower (typeof) et caster (as number) ?
Prochaine étape

Tu sais maintenant écrire l'incertitude et la résoudre par le narrowing. À la leçon 5, on passe au contrat complet d'une fonction : paramètres typés, type de retour, optionnels, tout ce qu'il faut pour que l'IA génère exactement ce que tu attends.

Leçon 5 : Des fonctions bien typées →
Besoin d'un développeur pour votre projet ?

Réponse sous 24h · Sans engagement