Tu as une variable de type string | number. Tu écris un if, et soudain, dans
le bloc, TypeScript la traite comme une string pure — sans que tu aies rien
annoté. Ce n’est pas de la magie : c’est le narrowing, l’analyse de flot qui
restreint un type au fil des conditions.
TypeScript lit tes conditions
Le compilateur suit l’exécution comme tu la lirais, et rétrécit le type dans chaque branche :
function format(x: string | number) {
if (typeof x === "string") {
return x.toUpperCase(); // ici x est string
}
return x.toFixed(2); // ici x est forcément number
}Dans le if, x est string. Après le return, la branche string est
éliminée, donc x ne peut plus être que number. Le type devient une preuve
qui se construit ligne après ligne.
Plusieurs gardes possibles
Le narrowing ne se limite pas à typeof. TypeScript comprend aussi :
// truthiness
if (user) { /* user n'est plus null | undefined */ }
// égalité
if (status === "ok") { /* status vaut littéralement "ok" */ }
// in
if ("wings" in animal) { /* animal a la propriété wings */ }
// instanceof
if (err instanceof TypeError) { /* err est un TypeError */ }Chacune de ces formes est un indice que le compilateur sait interpréter pour réduire l’ensemble des types possibles.
Le piège : ce qui casse le narrowing
L’analyse de flot suit le code, mais elle peut le perdre. Un appel de fonction entre la garde et l’usage, par exemple, et TypeScript ne garantit plus rien :
function f(x: string | null) {
if (x === null) return;
doSomething(); // et si ça remettait x à null ?
x.toUpperCase(); // x est encore string ici — TS le sait :
} // il parie qu'un appel ne réassigne pas une localeMais avec une propriété d’objet mutable, le narrowing est perdu après tout
appel intermédiaire : TS ne peut pas prouver qu’elle n’a pas changé. La règle
pratique : narrow sur des valeurs stables, copie dans une const si besoin.
Quand une seule garde ne suffit plus à distinguer plusieurs formes, on passe au cran au-dessus : les unions discriminées, où un champ tag rend chaque cas exclusif. C’est la suite naturelle du narrowing.
You have a variable of type string | number. You write an if, and suddenly,
inside the block, TypeScript treats it as a pure string — without you annotating
anything. It’s not magic: it’s narrowing, the control-flow analysis that
restricts a type as conditions accumulate.
TypeScript reads your conditions
The compiler follows execution the way you’d read it, and narrows the type in each branch:
function format(x: string | number) {
if (typeof x === "string") {
return x.toUpperCase(); // here x is string
}
return x.toFixed(2); // here x must be number
}In the if, x is string. After the return, the string branch is gone, so
x can only be number. The type becomes a proof built line by line.
Many possible guards
Narrowing isn’t limited to typeof. TypeScript also understands:
// truthiness
if (user) { /* user is no longer null | undefined */ }
// equality
if (status === "ok") { /* status is literally "ok" */ }
// in
if ("wings" in animal) { /* animal has the wings property */ }
// instanceof
if (err instanceof TypeError) { /* err is a TypeError */ }Each of these forms is a clue the compiler knows how to read to shrink the set of possible types.
The trap: what breaks narrowing
Flow analysis follows the code, but it can lose track. A function call between the guard and the use, for instance, and TypeScript no longer guarantees anything:
function f(x: string | null) {
if (x === null) return;
doSomething(); // what if this set x back to null?
x.toUpperCase(); // x is still string here — TS knows it:
} // it bets a call won't reassign a localBut with a mutable object property, narrowing is lost after any intermediate
call: TS can’t prove it didn’t change. The practical rule: narrow on stable
values, copy into a const if needed.
When a single guard no longer distinguishes several shapes, you step up a level: discriminated unions, where a tag field makes each case exclusive. It’s the natural sequel to narrowing.