TypeScript en pratique - 14 - Performance du compilateur

Diagnostiquer et corriger les problèmes de performance de tsc : traces, types recursifs, project références et astuces pour accélérer la compilation.

14 - Performance du compilateur

Ce que tu vas apprendre

  • Comment détecter que la compilation est lente
  • Utiliser tsc --generateTrace pour diagnostiquer
  • Les patterns qui ralentissent le compilateur (types recursifs, unions massives, infer complexes)
  • Les project références pour la compilation incrementale
  • L'impact de skipLibCheck
  • La différence de performance entre interface et type
  • Des astuces concrètes avec des chiffres réels

Prerequisites

Avoir lu l'article sur la migration JS vers TS progressive.


Quand la compilation devient un problème

Sur un petit projet, tsc prend quelques secondes. Sur un projet de 500 fichiers, ca monte a 10-15 secondes. Sur un monorepo avec des types complexes, j'ai vu des compilations de 45 secondes a plus d'une minute.

Le seuil ou ca fait mal : quand le feedback loop du tsc --watch dépassé 3-4 secondes. Au-dela, les développeurs arretent d'attendre le compilateur et perdent le benefice du typage en temps réel.

Mesurer avant d'optimiser

Première chose : mesure le temps de compilation actuel.

bashtime tsc --noEmit

Sur un de mes projets :

real    0m12.340s
user    0m0.015s
sys     0m0.031s

12 secondes. C'est trop pour un --watch. On va voir comment descendre a 3 secondes.

tsc --generateTrace

TypeScript a un outil de diagnostic intégré :

bashtsc --generateTrace ./trace-output --noEmit

Ca généré un dossier avec des fichiers JSON. Ouvre trace.json dans Chrome DevTools (onglet Performance, bouton "Load profile") ou dans Perfetto.

Tu verras :

  • Le temps passe sur chaque fichier
  • Les verifications de types les plus couteuses
  • Les resolutions de modules
  • Les instanciations de generics

Le fichier types.json montre les types les plus gros avec leur nombre d'instanciations.

Les patterns qui ralentissent tsc

Types recursifs profonds

typescript// ❌ Lent : type recursif sans limite
type DeepPartial<T> = {
  [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K]
}

// Applique sur un type avec 5 niveaux d'imbrication et 50 proprietes
// Le compilateur explose combinatoirement
type Config = DeepPartial<HugeNestedConfig>

Solution : limiter la profondeur de recursion.

typescript// ✅ Mieux : limite explicite
type DeepPartial<T, Depth extends number[] = []> =
  Depth["length"] extends 4
    ? T
    : {
        [K in keyof T]?: T[K] extends object
          ? DeepPartial<T[K], [...Depth, 0]>
          : T[K]
      }

Unions massives

typescript// ❌ Lent : union de 200+ types litteraux
type CountryCode = "AF" | "AL" | "DZ" | "AS" | /* ... 200 autres ... */ | "ZW"

// Chaque verification contre cette union teste tous les membres
function isValid(code: string): code is CountryCode {
  return allCodes.includes(code as CountryCode)
}

Quand une union dépassé 50 membres, le compilateur ralentit sur les verifications de compatibilité. Solutions :

typescript// ✅ Option 1 : string avec un branded type
type CountryCode = string & { __brand: "CountryCode" }

// ✅ Option 2 : enum (plus rapide que les unions litterales pour de gros ensembles)
enum CountryCode {
  AF = "AF",
  AL = "AL",
  // ...
}

Infer excessif dans les types conditionels

typescript// ❌ Lent : chaque condition force le compilateur a tenter l'inference
type ExtractRouteParams<T extends string> =
  T extends `${infer _Start}:${infer Param}/${infer Rest}`
    ? { [K in Param | keyof ExtractRouteParams<Rest>]: string }
    : T extends `${infer _Start}:${infer Param}`
      ? { [K in Param]: string }
      : {}

// Sur des routes longues, ca devient quadratique
type Params = ExtractRouteParams<"/api/v1/:org/:team/:project/:branch/:file">

Le compilateur essaie chaque branche du conditionnel, avec des backtracks. Plus la string est longue, plus c'est lent.

Intersection de gros types

typescript// ❌ Lent : intersection de types avec beaucoup de proprietes
type FullConfig = BaseConfig & DatabaseConfig & CacheConfig & AuthConfig & LoggingConfig
// Si chaque type a 30 proprietes, le compilateur doit verifier les 150 proprietes
// a chaque utilisation de FullConfig

Solution : utilise interface extends au lieu des intersections.

typescript// ✅ Plus rapide : interface extends
interface FullConfig extends BaseConfig, DatabaseConfig, CacheConfig, AuthConfig, LoggingConfig {}

interface vs type : la différence de performance

TypeScript traite interface et type differemment en interne :

  • Les interfaces sont eagerly resolved : le compilateur les resout une fois et met en cache.
  • Les types (surtout les intersections et les mapped types) sont lazily resolved : recalcules a chaque utilisation.
typescript// ✅ Plus rapide — resolu une fois
interface UserWithPosts extends User {
  posts: Post[]
}

// ❌ Plus lent — recalcule a chaque usage
type UserWithPosts = User & {
  posts: Post[]
}

La différence est negligeable sur les petits types. Elle est mesurable sur les types avec des dizaines de propriétés utilises des centaines de fois.

Mon conseil : utilise interface par défaut pour les objets. Reserve type pour les unions, les mapped types et les types conditionels (ou interface ne peut pas faire le travail).

Project références pour la compilation incrementale

On en a parle dans l'article sur les monorepos. Les project références sont le levier le plus impactant pour la performance.

bash# Sans project references
tsc --noEmit  # 12s

# Avec project references (premiere compilation)
tsc --build   # 14s (un peu plus car genere les .d.ts)

# Avec project references (apres modification d'un fichier)
tsc --build   # 2s (seul le package modifie est recompile)

Le gain vient du fait que tsc --build utilise les fichiers .d.ts générés au lieu de recompiler les sources des dépendances.

skipLibCheck

skipLibCheck: true dit au compilateur de ne pas vérifier les fichiers .d.ts dans node_modules :

jsonc{
  "compilerOptions": {
    "skipLibCheck": true
  }
}

Impact sur un de mes projets :

skipLibCheck: false  ->  15.2s
skipLibCheck: true   ->  9.8s

35% de gain. Le compromis : si une dépendance a des erreurs de types, tu ne les verras pas. En pratique, c'est rarement un problème parce que les libs typees sont testees avant publication.

Sur paltemps.fr, skipLibCheck: true est active sur tous les packages. Je n'ai jamais eu de problème cause par cette option.

isolatedModules

isolatedModules: true force chaque fichier a etre compilable indépendamment. C'est un prerequis pour les transpileurs fichier par fichier (esbuild, swc, Babel) :

jsonc{
  "compilerOptions": {
    "isolatedModules": true
  }
}

Ca interdit certains patterns (const enum cross-fichier, namespace merging) mais ca permet a esbuild de transpiler 100x plus vite que tsc parce qu'il n'a pas besoin du graphe de dépendances complet.

Astuces concrètes

1. Reduis les reexports

Comme vu dans l'article sur les monorepos, les barrel files (index.ts qui reexportent tout) forcent le compilateur a charger des fichiers inutiles.

2. Evite les types conditionels distribues

typescript// ❌ Distribue sur chaque membre de l'union
type Result<T> = T extends Error ? "error" : "ok"
type R = Result<string | Error>  // "ok" | "error"

// Le compilateur teste chaque membre separement
// Sur une union de 50 types, ca fait 50 evaluations

3. Utilise const assertions au lieu de types larges

typescript// ❌ Le compilateur doit verifier que le tableau est compatible avec string[]
const routes: string[] = ["/home", "/about", "/contact"]

// ✅ Le type est infere directement comme tuple
const routes = ["/home", "/about", "/contact"] as const

4. Prefere les tuples aux tableaux dans les types

typescript// ❌ Array<string | number> — chaque acces doit etre verifie
type Row = (string | number)[]

// ✅ Tuple — chaque position a un type fixe
type Row = [string, number, number, string]

5. Surveille les instanciations de generics

Dans la trace, regarde le nombre d'instanciations. Si un type générique est instancie 10 000 fois, c'est probablement un problème.

bash# Dans types.json de la trace
# Cherche les types avec un count > 1000

Chiffres réels : avant/apres

Sur un projet avec 450 fichiers TypeScript :

Optimisation Temps avant Temps apres Gain
skipLibCheck: true 15.2s 9.8s -35%
Supprimer les barrel files 9.8s 7.1s -28%
interface au lieu de type & 7.1s 6.2s -13%
Project références 6.2s 2.1s (incremental) -66%

Total : de 15.2s a 2.1s en incremental. Le --watch est redevenu instantane.

Quand utiliser un transpileur externe

Si tu n'as besoin que de la transpilation (pas du type checking), utilise esbuild ou swc :

bash# Transpilation seule — pas de type checking
esbuild src/index.ts --bundle --outfile=dist/index.js

# Type checking separe
tsc --noEmit

esbuild transpile en ~50ms ce que tsc met 10 secondes a transpiler + type-checker. Le type checking reste nécessaire, mais il peut tourner en parallèle ou en CI.


Résumé

  • tsc --generateTrace est l'outil de diagnostic principal pour les problèmes de performance
  • Les types recursifs profonds, les unions massives et les infer complexes sont les causes les plus courantes de lenteur
  • interface est plus rapide que type pour les objets (résolution eagre vs lazy)
  • skipLibCheck: true donne 30-40% de gain sans risque réel
  • Les project références transforment le temps incremental de 10s+ a 1-2s
  • Separer transpilation (esbuild) et type checking (tsc --noEmit) est l'approche la plus rapide

Article précédent : 13 - Migration JS vers TS

Article suivant : 15 - Types et tests

Sources

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