02 - Inference, widening et narrowing
Ce que tu vas apprendre
- Comment TypeScript devine les types sans annotations
- Ce qu'est le widening et pourquoi
let x = "hello"n'a pas le type"hello" - Ce qu'est le narrowing et comment il retreci un type dans un bloc
- Quand annoter et quand laisser le compilateur faire
Prerequisites
Avoir lu l'article sur any, unknown, never.
TypeScript devine mieux que toi
Un reflexe de débutant en TypeScript, c'est d'annoter chaque variable :
typescriptconst name: string = "Nicolas"
const age: number = 31
const isAdmin: boolean = true
const users: string[] = ["Alice", "Bob"]
Ces annotations sont inutiles. TypeScript infere deja les bons types à partir des valeurs assignees. Le code suivant est identique en termes de typage :
typescriptconst name = "Nicolas" // type: "Nicolas" (literal type)
const age = 31 // type: 31
const isAdmin = true // type: true
const users = ["Alice", "Bob"] // type: string[]
Le compilateur lit la valeur a droite du = et en deduit le type. C'est l'inference de type. Elle fonctionne pour les variables, les retours de fonctions, les paramètres par défaut, les destructurations.
typescriptfunction add(a: number, b: number) {
return a + b
}
// retour infere : number — pas besoin d'annoter
const result = add(2, 3) // type: number
La regle que j'applique : annote les paramètres de fonctions, laisse le compilateur inferer le reste. Si tu annotes le retour d'une fonction, c'est un choix delibere (contrat d'API), pas une obligation.
Widening : quand TypeScript elargit le type
Regarde la différence entre const et let :
typescriptconst status = "active" // type: "active" (literal type)
let status2 = "active" // type: string
Avec const, TypeScript sait que la valeur ne changera jamais. Il garde le type literal "active". Avec let, la variable peut etre reassignee. TypeScript elargit le type a string pour permettre status2 = "inactive" plus tard. C'est le widening.
Le widening se produit dans plusieurs contextes :
typescript// Variables let
let x = 42 // type: number (pas 42)
let y = true // type: boolean (pas true)
// Proprietes d'objets
const config = {
port: 3000, // type: number (pas 3000)
host: "localhost" // type: string (pas "localhost")
}
// config.port = 8080 est valide — donc TypeScript elargit
// Tableaux
const items = [1, 2, 3] // type: number[] (pas [1, 2, 3])
Le widening a du sens : un objet mutable doit accepter de nouvelles valeurs du meme type. Mais parfois, tu veux garder le type literal. C'est la que as const intervient :
typescriptconst config = {
port: 3000,
host: "localhost"
} as const
// type: { readonly port: 3000; readonly host: "localhost" }
Plus de widening. Les valeurs sont figees a leur type literal, et les propriétés deviennent readonly. L'article sur l'immutabilité couvre as const en détail.
Le piège du widening avec les fonctions
Un cas qui piège souvent :
typescripttype Status = "active" | "inactive" | "banned"
function setStatus(status: Status) {
// ...
}
let s = "active"
setStatus(s) // ❌ Erreur : string n'est pas assignable a Status
s a le type string a cause du widening. La solution : utiliser const, ou annoter explicitement.
typescriptconst s = "active" // type: "active" ✅
setStatus(s)
// ou
let s: Status = "active" // annotation explicite ✅
setStatus(s)
Narrowing : quand TypeScript retreci le type
Le narrowing est l'inverse du widening. C'est quand TypeScript réduit un type large a un type plus precis grâce à une vérification dans le code.
typescriptfunction format(value: string | number) {
// ici, value est string | number
if (typeof value === "string") {
// ici, value est string — TypeScript l'a retreci
return value.toUpperCase()
}
// ici, value est number — TypeScript l'a deduit par elimination
return value.toFixed(2)
}
Le compilateur analyse le flux de contrôle (control flow analysis). Il sait qu'apres le if (typeof value === "string"), dans le bloc if, la valeur est forcement un string. Et dans le else (implicite ici), c'est forcement un number.
Les opérateurs qui declenchent le narrowing
TypeScript reconnait plusieurs patterns :
typescript// typeof
if (typeof x === "string") { /* x est string */ }
// instanceof
if (x instanceof Date) { /* x est Date */ }
// in
if ("name" in x) { /* x a une propriete name */ }
// Comparaison avec null/undefined
if (x !== null) { /* x sans null */ }
if (x !== undefined) { /* x sans undefined */ }
// Egalite
if (x === "admin") { /* x est "admin" */ }
// Truthiness
if (x) { /* x sans null, undefined, 0, "", false */ }
Control flow analysis en profondeur
Le narrowing survit aux retours anticipees :
typescriptfunction process(input: string | null) {
if (input === null) {
return // early return
}
// Ici TypeScript sait que input est string
// Le null a ete elimine par le return
console.log(input.toUpperCase())
}
C'est un pattern que j'utilise en permanence sur les projets paltemps.fr. Plutot que d'imbriquer des if, je fais des early returns pour eliminer les cas invalides en haut de la fonction. Le code qui suit est automatiquement narrowe au type valide.
Ca fonctionne aussi avec throw :
typescriptfunction getUser(id: string | undefined) {
if (!id) {
throw new Error("ID requis")
}
// id est string ici
return db.users.findUnique({ where: { id } })
}
Les limites du narrowing
Le narrowing ne traverse pas les callbacks :
typescriptfunction example(value: string | null) {
if (value !== null) {
// value est string ici ✅
setTimeout(() => {
// value est string | null ici ❌
// TypeScript ne garantit pas que value n'a pas change
// entre le if et l'execution du callback
console.log(value.toUpperCase()) // erreur
}, 100)
}
}
C'est une limitation deliberee. Entre le moment du if et l'exécution du callback, la variable pourrait theoriquement avoir change (si c'etait un let). La solution : assigner a une const locale.
typescriptfunction example(value: string | null) {
if (value !== null) {
const safeValue = value // type: string, ne changera pas
setTimeout(() => {
console.log(safeValue.toUpperCase()) // ✅
}, 100)
}
}
Quand annoter, quand laisser inferer
Ma regle perso, construite apres des annees de TypeScript :
Annote quand tu veux un contrat explicite :
- Paramètres de fonctions (toujours)
- Retour de fonctions publiques ou exportees (contrat d'API)
- Variables qui doivent accepter un type plus large que la valeur initiale
Laisse inferer quand le compilateur fait deja le bon travail :
- Variables locales initialisees
- Retour de fonctions privees ou simples
- Destructurations
- Résultats de map/filter/reduce
typescript// ✅ Le compilateur infere correctement
const users = await prisma.user.findMany()
const names = users.map(u => u.name)
const active = users.filter(u => u.status === "active")
// ✅ Annotation utile — contrat d'API public
export function createUser(data: CreateUserInput): Promise<User> {
// ...
}
L'inference excessive n'est pas un problème. L'annotation excessive l'est : elle créé du bruit visuel et peut masquer des erreurs quand le type annote ne correspond plus a la réalité du code.
Résumé
- L'inference de type évité les annotations redondantes — annote les paramètres, laisse le compilateur inferer le reste
- Le widening elargit les types literals quand la variable est mutable (
let, propriétés d'objets) - Le narrowing retreci les types dans les blocs conditionnels grace au control flow analysis
as constempeche le widening et fige les valeurs a leur type literal- Le narrowing ne traverse pas les callbacks — utilise des
constlocales dans ce cas
Article précédent : 01 - any, unknown, never : le trio que personne ne comprend
Article suivant : 03 - Valeurs vs références : copies, mutations et spread traps