TypeScript le système de types - 02 - Inference, widening et narrowing

Comment TypeScript devine les types, quand il elargit ou retreci un type, et quand tu dois annoter explicitement.

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 const empeche le widening et fige les valeurs a leur type literal
  • Le narrowing ne traverse pas les callbacks — utilise des const locales 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

Sources

Réservez un audit gratuit de 30 minutes. Je vous montre concrètement ce qu'on peut automatiser.