12k
All articles

Bonnes pratiques TypeScript pour les grands projets

Bonnes pratiques TypeScript pour grands projets : mode strict, noUncheckedIndexedAccess, exactOptionalPropertyTypes, types générés, validation runtime et garde-fous CI.

OpenReplay Team
OpenReplay Team
Bonnes pratiques TypeScript pour les grands projets

À grande échelle, TypeScript ne tient ses promesses qu’avec une cohérence rigoureuse : le mode strict comme référence de base, des frontières explicites et validées, des types générés aux points d’entrée pour éviter toute dérive entre équipes, et un ensemble restreint de patterns sur lesquels toute l’équipe s’accorde réellement. Le langage lui-même a cessé d’être le problème difficile depuis des années — le vrai défi, c’est de maintenir un code de plusieurs millions de lignes, développé par de nombreux contributeurs, suffisamment refactorisable pour qu’aucune régression de sûreté de types ne s’infiltre à chaque PR. Ce guide couvre les conventions, les flags du compilateur et les patterns architecturaux qui tiennent la route à cette échelle, avec une trajectoire de migration pour le code hérité aux contraintes relâchées que vous avez probablement récupéré — et il est à jour pour les deux versions qui ont redéfini le paysage de 2026 : TypeScript 6.0 (GA) et 7.0 (Release Candidate).

Points clés à retenir

  • Depuis TypeScript 6.0 (publié le 23 mars 2026), strict est activé par défaut au niveau du compilateur. Sur un grand projet moderne, le mode strict est donc le point de départ, non l’objectif final.
  • Les deux flags qui font réellement la différence sur un grand projet ne font pas partie de strict : noUncheckedIndexedAccess et exactOptionalPropertyTypes doivent être activés explicitement, et ils détectent les bugs liés aux accès par index et aux propriétés optionnelles que strict laisse passer silencieusement.
  • Les types générés constituent la pratique à plus fort effet de levier pour un projet multi-équipes : lorsque le frontend et le backend dérivent tous deux leurs types d’un même schéma OpenAPI ou Prisma, les deux côtés ne peuvent physiquement pas diverger, et la CI échoue dès que le contrat change.
  • Les types statiques sont une promesse à la compilation, pas une vérification à l’exécution — une réponse typée comme User n’est qu’affirmée comme telle, ce qui explique pourquoi chaque frontière externe nécessite une validation à l’exécution en plus d’un type généré.
  • Microsoft indique que le compilateur Go de TypeScript 7.0 est souvent environ 10× plus rapide que la version 6.0 sur les grands projets, et dans sa Release Candidate de juin 2026, il est intégré directement dans le binaire tsc standard et le package typescript.

Discipline du compilateur : le mode strict est le plancher, pas le plafond

Tout article superficiel sur les bonnes pratiques vous dit encore d’« activer le mode strict » comme si c’était un opt-in héroïque. Ce cadrage est désormais obsolète. Depuis les notes de version de TypeScript 6.0, strict est à true par défaut au niveau du compilateur — si vous vous appuyiez sur l’ancienne valeur par défaut false, vous devez maintenant définir "strict": false explicitement. TypeScript 6.0 a été annoncé le 23 mars 2026 et est destiné à être la dernière version basée sur la base de code JavaScript actuelle. Ainsi, sur tout projet qui met à jour son compilateur, strict est la référence supposée.

La véritable amélioration pour les grands projets réside dans les deux flags à haute valeur que strict n’inclut pas. strict active environ neuf vérifications de sûreté de types (noImplicitAny, strictNullChecks et consorts), mais il exclut noUncheckedIndexedAccess, qui ajoute undefined à tout accès par index non déclaré, et exactOptionalPropertyTypes, qui distingue une propriété définie à undefined d’une propriété absente. Ces flags détectent précisément les bugs qui passent au travers d’un projet « strict » : l’accès à un tableau qui suppose qu’un élément existe, et le champ optionnel présent mais à undefined.

Un tsconfig.json versionné pour grand projet (TypeScript 6.0.x) :

{
  "compilerOptions": {
    "strict": true,                      // par défaut depuis la 6.0 ; à conserver explicitement pour les anciennes toolchains
    "noUncheckedIndexedAccess": true,    // arr[i] est T | undefined, pas T
    "exactOptionalPropertyTypes": true,  // { x?: number } rejette { x: undefined }
    "verbatimModuleSyntax": true,        // impose les imports type-only (voir perf de build)
    "noImplicitOverride": true,
    "noFallthroughCasesInSwitch": true,
    "composite": true                    // requis pour les project references
  }
}

Le guide de migration vers une strictness progressive

Si vous avez hérité d’un projet aux contraintes relâchées, n’activez pas tous les flags d’un coup — appliquez la rigueur répertoire par répertoire, suivez un pourcentage de type-coverage en CI, et relevez le seuil progressivement pour que la couverture ne puisse jamais régresser. Une application de 200 000 lignes avec des milliers de any implicites ne compilera pas proprement dès le premier jour, et une PR avec 2 800 erreurs est impossible à relire.

Une séquence réaliste :

  1. Activez strict: true globalement mais encadrez l’application : conservez un tsconfig de base permissif et ajoutez des fichiers tsconfig.json plus stricts par répertoire de fonctionnalité en utilisant les project references, en commençant par les zones les plus problématiques.
  2. Ajoutez type-coverage à la CI comme cliquet — faites échouer le build si le pourcentage de symboles typés descend en dessous du dernier chiffre enregistré. La couverture peut plafonner, mais elle ne peut jamais régresser.
  3. Introduisez les flags supplémentaires (noUncheckedIndexedAccess, exactOptionalPropertyTypes) avec // @ts-expect-error sur les violations restantes, puis réduisez la liste. @ts-expect-error se signale de lui-même quand une suppression devient inutile, ce qui empêche le backlog de se dégrader silencieusement.

Conception des types à grande échelle

De bons types à grande échelle rendent les états illégaux non compilables et font ressortir bruyamment les erreurs de domaine. Trois patterns font l’essentiel du travail ; le reste relève de la cohérence.

La règle interface vs type, énoncée une fois pour toutes : utilisez interface pour les contrats d’objets publics et extensibles (elle supporte la fusion de déclarations et tend à produire de meilleurs messages d’erreur sur les grandes structures d’objets), et utilisez type pour les unions, intersections, types mappés et types conditionnels. C’est tout le débat. Choisissez la règle, appliquez-la via un linter, et passez à autre chose.

Rendre les états impossibles non représentables

La prolifération de flags booléens est la source la plus fréquente de bugs « ça ne devrait jamais arriver » dans un grand projet UI. Le type ci-dessous autorise seize combinaisons, dont la plupart n’ont aucun sens — isLoading et error activés simultanément, data présent lors d’une erreur :

// Anti-pattern : chaque champ indépendant, états impossibles autorisés
interface RequestState<T> {
  isLoading: boolean;
  isError: boolean;
  data?: T;
  error?: Error;
}

Une union discriminée réduit cela exactement aux états qui peuvent se produire, et le compilateur vous force à gérer chacun d’eux :

type RequestState<T> =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: T }
  | { status: "error"; error: Error };

function render<T>(state: RequestState<T>) {
  switch (state.status) {
    case "success":
      return state.data;   // data n'existe qu'ici
    case "error":
      return state.error;  // error n'existe qu'ici
    // un cas manquant est une erreur de compilation avec une vérification d'exhaustivité appropriée
  }
}

Les replays de session sur des états de requête à flags booléens révèlent fréquemment le mode d’échec que ce pattern élimine : une UI qui affiche simultanément un spinner et des données obsolètes parce que deux booléens indépendants se sont désynchronisés.

Brandez vos identifiants de domaine

Les types brandés font de UserId et OrderId des types incompatibles même si les deux sont des string à l’exécution, rendant une erreur de compilation le fait de passer l’un là où l’autre est attendu :

declare const brand: unique symbol;
type Brand<T, B> = T & { readonly [brand]: B };

type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;

const asUserId = (s: string) => s as UserId;

function cancelOrder(id: OrderId) { /* ... */ }

const u = asUserId("u_123");
cancelOrder(u); // ❌ Argument of type 'UserId' is not assignable to 'OrderId'

Dans une signature de fonction avec cinq arguments de type string, le branding fait la différence entre un bug d’arguments transposés détecté à la compilation et un bug découvert en production.

Préférez unknown à any. any désactive le vérificateur de types et se propage silencieusement ; unknown impose une étape de narrowing avant utilisation. Bannissez any dans le linter et traitez toute valeur externe — JSON.parse, les bindings de catch, les retours de bibliothèques non typées — comme unknown jusqu’à preuve du contraire. Utilisez as const sur les configurations littérales et les tables de correspondance pour qu’elles soient inférées comme des types littéraux étroits plutôt que des primitives élargies.

Modélisez et générez les types à vos frontières

La décision architecturale à plus fort effet de levier dans un grand projet est la façon dont vous typez les points d’entrée. Deux règles.

Premièrement, ne réutilisez pas un seul type User à travers le fil réseau, la base de données et l’UI — modélisez la réponse API, le DTO et l’entité de domaine comme trois types distincts, afin qu’un changement à une frontière ne puisse pas corrompre silencieusement une autre. La forme que votre backend sérialise, celle que votre ORM retourne et celle que vos composants consomment divergent avec le temps ; les fusionner en un seul type couple chaque couche à toutes les autres.

Deuxièmement, générez les types aux frontières plutôt que de les écrire à la main. Lorsque le frontend et le backend dérivent tous deux leurs types d’un même schéma, les deux côtés ne peuvent physiquement pas diverger, et la CI échoue dès que le contrat change. Utilisez openapi-typescript pour transformer un document OpenAPI 3.0/3.1 en types sans runtime, Prisma pour les types dérivés de la base de données, ou GraphQL Code Generator pour les opérations typées. Régénérez en CI et faites échouer le build en cas de diff :

# Étape CI : régénérer et échouer si les types committés sont obsolètes
npx openapi-typescript ./openapi.yaml -o ./src/api/schema.gen.ts
git diff --exit-code ./src/api/schema.gen.ts

Mais un type généré reste une simple promesse à la compilation. Les types statiques sont une promesse à la compilation, pas une vérification à l’exécution — une réponse typée comme User n’est qu’affirmée comme telle, ce qui explique pourquoi chaque frontière externe nécessite une validation à l’exécution en plus d’un type généré. Validez le payload réel avec une bibliothèque de schémas comme Zod ou Valibot et dérivez le type statique depuis le schéma, afin qu’une seule définition protège les deux couches :

import { z } from "zod";

const User = z.object({ id: z.string(), email: z.email() });
type User = z.infer<typeof User>;

const res = await fetch("/api/me");
const user = User.parse(await res.json()); // lève une exception si la forme réelle ne correspond pas

L’argument en faveur de la validation des frontières, et pas seulement de leur typage, est empirique : les types statiques disparaissent à l’exécution, et le replay de session est une technique pour voir les échecs que les types ne peuvent pas détecter — le moment où une réponse API réelle ne correspond pas à sa forme déclarée et où l’UI se casse dans la session d’un utilisateur.

Organiser les types pour un projet multi-contributeurs

  • Colocalisez les types avec le code qui les utilise — même fichier, ou un fichier *.types.ts adjacent — et réservez un types/index.ts (ou un package dédié) uniquement pour les contrats véritablement partagés.
  • Organisez par dossier de fonctionnalité/domaine, et non par couche technique, afin que les types, composants et logique d’une fonctionnalité coexistent et que la propriété soit évidente.
  • Dans un monorepo, connectez les packages entre eux avec les project references et le mapping paths pour que les imports traversent des frontières de modules propres (@org/billing) plutôt que des chemins fragiles en ../../../, et pour que le compilateur applique le graphe de dépendances.

Performance de build en 2026 : le compilateur natif change la donne

La conversation sur la performance de build a fondamentalement changé, et il ne s’agit plus de gratter quelques secondes avec des flags tsc. TypeScript a annoncé la Release Candidate 7.0 le 18 juin 2026 ; avec la vitesse du code natif et le parallélisme en mémoire partagée, elle est souvent environ dix fois plus rapide que TypeScript 6.0. Le benchmark phare de Microsoft a vérifié le code VS Code (~1,5 million de lignes) en environ 7,5 secondes contre 77,8 secondes avec le compilateur précédent, bien que le ratio soit plus faible sur les petits projets.

Le point crucial concerne le packaging. Le principal changement pratique dans la RC est le packaging : la réécriture en Go est passée d’un package native-preview séparé vers le package npm TypeScript standard, de sorte que TypeScript 7.0 est maintenant prêt pour des tests plus larges en tant que compilateur tsc standard. Installez-le avec npm install -D typescript@rc et exécutez le binaire tsc standard — les anciens packages tsgo / @typescript/native-preview ne contiennent désormais que des nightlies. Fin juin 2026, la dernière version stable est TypeScript 6.0.3, avec la 7.0 en RC et une GA stable attendue environ un mois après la RC. Considérez la version et le stade comme volatils et vérifiez à nouveau au moment de l’adoption.

Les leviers structurels que vous contrôlez dans votre propre configuration restent pertinents quel que soit le compilateur : les project references pour des builds incrémentaux et sensibles aux dépendances, et les imports type-only appliqués par verbatimModuleSyntax pour que les symboles type-only soient effacés et ne soient jamais émis comme imports à l’exécution. verbatimModuleSyntax (introduit en 5.0) est l’approche recommandée actuelle ; les flags qu’il a remplacés, importsNotUsedAsValues et preserveValueImports, ont été rendus sans effet en 5.5 et provoquent une erreur s’ils sont spécifiés depuis la version 6.0.

import type { User } from "./user";  // entièrement effacé de la sortie JS
import { fetchUser } from "./api";   // import de valeur, conservé

Automatisez les garde-fous

Les conventions qui ne sont pas appliquées se dégradent. Exécutez typescript-eslint avec des règles type-aware (no-explicit-any, no-floating-promises, no-misused-promises) pour que les patterns ci-dessus soient vérifiés mécaniquement, et non lors des revues de code. Laissez l’application des imports type-only à verbatimModuleSyntax plutôt qu’à la règle lint consistent-type-imports — exécuter les deux est redondant et peut produire des erreurs contradictoires. Exécutez tsc --noEmit en CI sur chaque PR comme un contrôle bloquant, aux côtés du cliquet type-coverage du guide de migration. Et conservez un garde-fou humain au-dessus de toute l’automatisation : la clarté avant l’ingéniosité. Un type conditionnel et mappé profondément imbriqué qui prend dix minutes à un ingénieur senior pour être lu est un passif, pas une démonstration de compétence — la plupart du code de types dans un grand projet devrait être ennuyeux, lisible et évident.

Le fil conducteur est la cohérence, non la sophistication. Activez les deux flags que strict omet, rendez vos états illégaux non compilables, générez et validez vos frontières, et laissez la CI appliquer le reste. Prenez le tsconfig versionné ci-dessus comme référence de départ cette semaine, puis pointez le cliquet type-coverage sur votre répertoire le plus problématique et commencez à progresser.

FAQ

Le mode strict est-il suffisant pour un grand projet TypeScript ?

Non. Depuis TypeScript 6.0, strict est déjà activé par défaut au niveau du compilateur, ce qui en fait la référence de base plutôt qu'un accomplissement. Les deux flags qui comptent le plus sur un grand projet ne font pas partie de la famille strict : noUncheckedIndexedAccess, qui ajoute undefined aux accès par index non déclarés, et exactOptionalPropertyTypes, qui distingue une propriété définie à undefined d'une propriété absente. Activez les deux explicitement, puis ajoutez des types aux frontières et une validation à l'exécution.

Quelle est la différence entre interface et type en TypeScript, et quand utiliser chacun ?

Utilisez interface pour les contrats d'objets publics et extensibles, car elle supporte la fusion de déclarations et tend à produire des messages d'erreur plus clairs sur les grandes structures d'objets. Utilisez type pour les unions, intersections, types mappés et types conditionnels, que interface ne peut pas exprimer. Pour une grande équipe, la règle pratique est de choisir cette convention une fois, de l'appliquer via une règle lint, et de cesser d'en débattre. Les deux compilent vers des vérifications de types identiques pour les structures d'objets simples, donc le choix porte sur l'expressivité et la cohérence, pas sur les capacités.

Les types générés depuis OpenAPI ou Prisma rendent-ils la validation à l'exécution inutile ?

Non. Un type généré est uniquement une promesse à la compilation. Une réponse JSON typée comme User est simplement affirmée comme correspondant à cette forme ; le compilateur n'inspecte jamais le payload réel à l'exécution, donc un changement backend ou un champ null passe quand même. Les types générés empêchent le frontend et le backend de diverger sur le contrat, mais vous avez toujours besoin d'une bibliothèque de schémas comme Zod ou Valibot pour valider le payload réel à chaque frontière externe. Dérivez le type statique depuis le schéma pour qu'une seule définition protège les deux couches.

Comment installer et exécuter TypeScript 7.0 à son stade de Release Candidate ?

Installez-le avec npm install -D typescript@rc et exécutez le binaire tsc standard. Dans la Release Candidate de juin 2026, le compilateur natif basé sur Go a quitté le package native-preview séparé pour intégrer le package npm typescript standard, donc il n'y a plus de binaire tsgo distinct pour la RC ; les anciens packages tsgo et typescript native-preview ne contiennent désormais que des nightlies. Microsoft indique que la 7.0 est souvent environ dix fois plus rapide que la 6.0 sur les grands projets. Considérez la version et le stade comme volatils et vérifiez à nouveau avant d'adopter.

Open-source session replay

Complete picture for complete understanding

Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.