14 - Performance du compilateur
Ce que tu vas apprendre
- Comment détecter que la compilation est lente
- Utiliser
tsc --generateTracepour 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
interfaceettype - 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 --generateTraceest l'outil de diagnostic principal pour les problèmes de performance- Les types recursifs profonds, les unions massives et les
infercomplexes sont les causes les plus courantes de lenteur interfaceest plus rapide quetypepour les objets (résolution eagre vs lazy)skipLibCheck: truedonne 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
- TypeScript Performance Wiki par Microsoft
- TypeScript --generateTrace par Microsoft
- Speeding up the TypeScript compiler par Matt Pocock