Développement Web

Migrer un projet JavaScript vers TypeScript pas à pas

11 دقائق للقراءة

📍 Guide principal de la série : TypeScript moderne — du JS au système de types industriel

Ce tutoriel détaille un plan de migration incrémental en sept étapes, applicable sur une vraie base de code sans bloquer la livraison.

Introduction

Migrer un projet JavaScript en TypeScript en bloc est rarement réaliste. Au-delà de quelques centaines de lignes, la stratégie « tout réécrire » échoue : trop d’erreurs simultanées, trop de décisions à prendre, livraison de fonctionnalités bloquée pendant la durée du chantier. TypeScript a été conçu dès l’origine pour permettre une adoption progressive, fichier par fichier, avec un retour de valeur immédiat dès le premier fichier converti. Ce tutoriel décrit un plan en sept étapes qui s’applique à une base réelle, et qui laisse la livraison de production se poursuivre pendant tout le chantier.

Prérequis

  • Un projet JavaScript existant avec un package.json.
  • Node.js 22 LTS ou supérieur installé.
  • Une suite de tests qui couvre raisonnablement le code à migrer — la migration s’appuie sur ces tests pour valider qu’aucune régression silencieuse n’a été introduite.
  • Un système de contrôle de version actif (Git) avec une branche dédiée à la migration.
  • Temps estimé : la première étape prend environ une heure. La migration complète d’un projet de taille moyenne s’étale sur plusieurs semaines, sans bloquer les autres travaux.

Étape 1 — Installer TypeScript sans rien casser

La première étape ne convertit aucun fichier. On installe simplement TypeScript dans le projet et on génère un tsconfig.json qui accepte le JavaScript existant. À l’issue de cette étape, le projet continue à fonctionner exactement comme avant — aucun comportement à l’exécution n’a changé.

npm install --save-dev typescript @types/node
npx tsc --init

On édite ensuite le tsconfig.json pour autoriser les fichiers JavaScript dans la compilation, activer le type-checking sur ces fichiers, et désactiver temporairement le mode strict pour ne pas être submergé par des centaines d’erreurs au premier passage.

{
  "compilerOptions": {
    "module": "nodenext",
    "target": "es2022",
    "rootDir": "./src",
    "outDir": "./dist",
    "allowJs": true,
    "checkJs": false,
    "strict": false,
    "noImplicitAny": false,
    "skipLibCheck": true,
    "esModuleInterop": true,
    "isolatedModules": true,
    "types": ["node"]
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

Le drapeau allowJs: true indique que les fichiers .js font partie du programme. checkJs: false les laisse passer sans vérification poussée — on activera la vérification plus tard. À ce stade, on lance npx tsc --noEmit pour vérifier qu’aucune erreur de syntaxe ne bloque la compilation. Si la sortie est silencieuse, l’étape est validée.

Étape 2 — Activer checkJs sur un fichier pilote

Plutôt que d’activer checkJs globalement, on l’active fichier par fichier au moyen du commentaire // @ts-check placé en tête. Ce commentaire dit au compilateur de vérifier ce fichier JavaScript précis comme s’il était TypeScript, sans changer son extension.

// src/utils/formatDate.js
// @ts-check

/**
 * @param {Date} date
 * @returns {string}
 */
function formatDate(date) {
  return date.toISOString().slice(0, 10);
}

module.exports = { formatDate };

Les annotations JSDoc fournissent les informations de type au compilateur. Pour un projet qui repose sur CommonJS et qui ne peut pas changer d’extension rapidement, cette approche permet de bénéficier du type-checking sans toucher à l’outillage de production. On choisit un fichier représentatif mais isolé pour piloter cette étape, idéalement un module utilitaire pur sans dépendance lourde.

Une fois le fichier annoté, on relance npx tsc --noEmit. Les erreurs qui apparaissent guident les corrections à apporter. Une fois le fichier propre, on passe au suivant.

Étape 3 — Convertir le premier fichier en .ts

Quand on s’estime à l’aise avec les annotations JSDoc, on franchit le pas en renommant un fichier .js en .ts. Le contenu peut rester identique au début — TypeScript accepte du JavaScript pur dans un fichier .ts tant qu’il n’utilise pas de syntaxe ambiguë. Ensuite, on remplace progressivement les commentaires JSDoc par des annotations natives, plus concises et plus puissantes.

// src/utils/formatDate.ts
export function formatDate(date: Date): string {
  return date.toISOString().slice(0, 10);
}

On en profite pour passer aux modules ES (export au lieu de module.exports) si le projet le permet, et pour ajouter les types explicites sur les paramètres et les retours. Le compilateur valide l’ensemble.

Si le fichier converti est importé par d’autres fichiers .js, ces imports continuent à fonctionner grâce à allowJs et à esModuleInterop. La compilation produit un .js équivalent dans dist/, et les anciens consommateurs ne voient pas la différence.

Étape 4 — Définir un ordre de conversion

Pour ne pas convertir au hasard, on hiérarchise. La règle la plus efficace est de partir des feuilles vers la racine : on convertit d’abord les modules utilitaires sans dépendance, puis les modules métier qui les consomment, puis les contrôleurs et l’orchestration. Cette approche garantit qu’à chaque étape, on importe du TypeScript déjà typé, ce qui propage automatiquement l’information de type vers le consommateur.

Trois critères aident à prioriser :

  • Stabilité. Les fichiers qui changent rarement migrent en premier — l’effort est amorti longtemps.
  • Criticité. Les modules dont les bugs coûtent cher (paiement, authentification, calculs financiers) bénéficient en premier du gain de sûreté.
  • Couverture de tests. Les modules bien testés migrent plus sûrement parce que la suite valide qu’on n’a rien cassé.

On évite de migrer les modules qui sont en cours de refonte fonctionnelle — la migration et le changement métier ne se mélangent pas bien dans la même pull request. Les tests, eux, peuvent être migrés en parallèle des fichiers qu’ils couvrent, ce qui est utile pour bénéficier des types lors de l’écriture de nouvelles assertions.

Étape 5 — Gérer les bibliothèques tierces non typées

La plupart des paquets npm exposent leurs propres déclarations de types, soit en interne soit via un paquet @types/* publié par DefinitelyTyped. Pour vérifier la disponibilité, on installe et on teste.

npm install --save-dev @types/lodash

Si le paquet ne dispose pas de déclarations, on a trois options. La plus rapide consiste à fournir une déclaration locale minimale dans src/types/nom-paquet.d.ts.

// src/types/legacy-lib.d.ts
declare module "legacy-lib" {
  export function doSomething(x: string): number;
  export const VERSION: string;
}

La deuxième option, valable pour une exploration ponctuelle, est d’utiliser any pour le module entier — solution qu’on accepte temporairement en sachant qu’elle ouvre un trou dans la sûreté de type.

// src/types/legacy-lib.d.ts
declare module "legacy-lib";

La troisième option, la plus durable, consiste à contribuer les déclarations à DefinitelyTyped pour en faire bénéficier toute la communauté. Cette contribution est généralement triviale techniquement, et profite à tous les consommateurs ultérieurs du paquet.

Étape 6 — Activer le mode strict progressivement

Quand suffisamment de fichiers sont en TypeScript, on commence à durcir la configuration. On active les options strict une par une, en corrigeant les erreurs qu’elles révèlent au fur et à mesure. L’ordre suivant fonctionne bien parce qu’il commence par les drapeaux à faible impact pour finir par les plus exigeants.

{
  "compilerOptions": {
    "noImplicitAny": true,
    "strictNullChecks": true,
    "strictFunctionTypes": true,
    "strictPropertyInitialization": true,
    "noImplicitThis": true,
    "useUnknownInCatchVariables": true,
    "noFallthroughCasesInSwitch": true,
    "noImplicitOverride": true
  }
}

Quand toutes ces options sont activées sans erreur, on peut basculer sur strict: true qui les regroupe. On ajoute ensuite les options plus exigeantes comme noUncheckedIndexedAccess et exactOptionalPropertyTypes, qui révèlent une dernière catégorie de pièges, principalement liés aux indexations dynamiques et aux propriétés optionnelles.

Pour les fichiers qui résistent — typiquement du code historique très peu testé — on peut isoler temporairement leurs erreurs avec // @ts-expect-error au-dessus de la ligne fautive. Le commentaire signale au compilateur qu’on attend une erreur, et émet un avertissement si l’erreur disparaît plus tard, ce qui force à nettoyer ces dettes au fur et à mesure que le code évolue.

Étape 7 — Mettre à jour la chaîne de build

Une fois la majorité du code en TypeScript, on adapte les outils du projet. Plusieurs scénarios existent selon l’environnement d’exécution.

Pour un service Node.js, on conserve tsc comme compilateur principal, ou on bascule sur un transpileur rapide (esbuild, swc) en gardant tsc uniquement pour le type-checking. Le script build du package.json appelle alors les deux : un type-check d’un côté, un transpile de l’autre.

{
  "scripts": {
    "typecheck": "tsc --noEmit",
    "build": "tsc",
    "build:fast": "esbuild src/index.ts --bundle --platform=node --outfile=dist/index.js",
    "test": "npm run typecheck && vitest run"
  }
}

Pour une application frontale avec Vite, esbuild, ou Webpack, le bundler gère déjà la transpilation des .ts nativement. On garde tsc --noEmit dans la pipeline CI pour la vérification de type, sans dupliquer la transpilation.

Pour la CI, on ajoute une étape de type-check explicite qui échoue dès qu’une erreur de type apparaît. Cette barrière empêche les régressions silencieuses sur les futurs pull requests.

# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
jobs:
  typecheck:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
      - run: npm ci
      - run: npm run typecheck
      - run: npm test

À la fin de l’étape 7, le projet est entièrement en TypeScript, vérifié par le compilateur en strict, et la pipeline rejette tout nouveau code qui briserait la sûreté de type. La migration est terminée.

Étape 8 — Vérification de la migration

Pour confirmer que le chantier est arrivé à son terme, on lance une série de contrôles. La présence éventuelle de fichiers .js restants doit être documentée ou justifiée.

# Nombre de fichiers TS et JS restants
find src -name "*.ts" -not -name "*.d.ts" | wc -l
find src -name "*.js" | wc -l

# Vérification de type stricte
npx tsc --noEmit

# Exécution des tests
npm test

Si le nombre de .js est nul (ou limité à des fichiers explicitement conservés en JavaScript), si tsc --noEmit ne signale aucune erreur en mode strict, et si les tests passent intégralement, la migration est complète. Le projet bénéficie désormais du système de types industriel sans avoir bloqué la livraison une seule fois.

Erreurs fréquentes

Symptôme Cause probable Solution
Cannot find module 'X' sur un module pourtant installé Pas de déclarations de types et noImplicitAny activé Installer @types/X ou créer une déclaration locale dans src/types/
Erreurs en cascade après activation de strictNullChecks Code historique qui suppose que les valeurs ne sont jamais null Activer progressivement, fichier par fichier, en utilisant // @ts-expect-error en dernier recours
Import d’un fichier .js qui échoue à l’exécution Imports sans extension en mode nodenext Ajouter l’extension .js dans l’import même si la source est en .ts
Tests qui passent mais types qui échouent Type-check absent de la pipeline CI Ajouter tsc --noEmit à la pipeline avant les tests
Outil de build qui ignore les types esbuild ou swc ne vérifient pas les types Garder tsc --noEmit en parallèle pour le contrôle
Variable 'X' implicitly has an 'any' type noImplicitAny activé sans annotations explicites Annoter le paramètre ou laisser le compilateur inférer depuis l’usage

Tutoriels complémentaires

FAQ

Doit-on tout migrer ou peut-on garder une partie en JavaScript ?
On peut garder du JavaScript indéfiniment grâce à allowJs. C’est utile pour les fichiers de configuration, les scripts utilitaires, ou les modules très anciens dont la conversion ne dégagerait pas de valeur. La règle pragmatique : convertir tout le code applicatif, garder le glue code historique tel quel.

Combien de temps prend une migration complète ?
Pour une base de 30 000 à 100 000 lignes, on compte généralement quatre à six semaines en menant la migration en parallèle des évolutions fonctionnelles, par un développeur dédié à mi-temps. Les bases plus anciennes ou plus complexes peuvent demander quelques mois.

Faut-il convertir les tests aussi ?
Oui, idéalement en même temps que le module qu’ils couvrent. Les tests bénéficient des types lors de l’écriture d’assertions et empêchent les fixtures de dériver silencieusement.

Que faire des fichiers de configuration webpack.config.js ou similaires ?
Ils peuvent être renommés en .ts si le bundler le supporte (webpack supporte webpack.config.ts depuis longtemps via ts-node), ou rester en JavaScript avec un commentaire // @ts-check et des annotations JSDoc.

L’option allowJs doit-elle rester active à la fin ?
Pas obligatoirement. Quand 100 % du code applicatif est en .ts, on peut désactiver allowJs. Si quelques scripts utilitaires restent en JavaScript, on garde l’option active. Le coût est négligeable.

Ressources et références

Service ITSkillsCenter

Site ou application web sur mesure

Conception Pro + Nom de domaine 1 an + Hébergement 1 an + Formation + Support 6 mois. Accès et code livrés. À partir de 350 000 FCFA.

Demander un devis
Publicité