Migrer un projet JavaScript vers TypeScript étape par étape

La bonne nouvelle, quand on veut passer un projet JavaScript en TypeScript, c'est qu'il n'y a pas de grand soir : le compilateur a été conçu pour avaler un dossier de fichiers .js tel quel et monter en exigence un cran à la fois. La mauvaise nouvelle, c'est que chaque cran fait apparaître des erreurs, et qu'un débutant ne sait pas lesquelles sont graves. Cet article suit une migration réelle du début à la fin, en notant à chaque étape ce que le compilateur a dit, combien d'erreurs il a comptées, et ce qu'elles voulaient dire.

Le banc d'essai : un petit programme Node.js de quatre fichiers et 66 lignes, migré pendant la rédaction avec TypeScript 7.0.2 (le tag latest de npm au 5 septembre 2026) et Node.js 20.19.4. Tous les messages sont cités tels quels, en anglais et avec leur code. Si vous n'avez jamais écrit une ligne de TypeScript, commencez plutôt par le projet todo-list en TypeScript : ici, on suppose que vous savez ce qu'est un type, et on s'intéresse à la méthode.

Le projet de départ : 66 lignes qui tournent, et un bug que personne n'a vu

Le programme lit un fichier CSV de dépenses et affiche un bilan par catégorie. Quatre modules ES dans src/ (le package.json porte "type": "module", ce qui fait lire les fichiers .js comme des modules ES par Node) : lecture.js lit le fichier, calcul.js fait les additions, format.js met en forme les euros, index.js orchestre le tout. Voici les deux fichiers qui comptent pour la suite, en JavaScript pur, tels qu'ils étaient avant la migration :

// src/lecture.js
import { existsSync, readFileSync } from "node:fs";

export function lireDepenses(chemin) {
    if (!existsSync(chemin)) {
        console.error(`Fichier introuvable : ${chemin}`);
        return;
    }
    const lignes = readFileSync(chemin, "utf8").trim().split("\n").slice(1);
    return lignes.map((ligne) => {
        const [date, categorie, intitule, montant] = ligne.split(";");
        return { date, categorie, intitule, montant: Number(montant) };
    });
}
// src/index.js
import { lireDepenses } from "./lecture.js";
import { totalGeneral, totalParCategorie, plusGrosseDepense } from "./calcul.js";
import { formaterEuros, formaterLigne } from "./format.js";

const chemin = process.argv[2] || "donnees/depenses.csv";
const depenses = lireDepenses(chemin);

console.log(`${depenses.length} dépenses lues dans ${chemin}\n`);

const totaux = totalParCategorie(depenses);
for (const categorie of Object.keys(totaux).sort()) {
    console.log(formaterLigne(categorie, totaux[categorie]));
}

console.log("-".repeat(26));
console.log(formaterLigne("Total", totalGeneral(depenses)));

const grosse = plusGrosseDepense(depenses);
console.log(`\nPlus grosse dépense : ${grosse.libelle} (${formaterEuros(grosse.montant, "EUR")})`);

Le fichier calcul.js contient trois fonctions sans surprise (totalGeneral, totalParCategorie qui remplit un objet {} catégorie par catégorie, et plusGrosseDepense qui part d'un let max = null). Lancé sur six dépenses d'août, le programme affiche ceci, avec un code de sortie 0, c'est-à-dire « tout va bien » :

6 dépenses lues dans donnees/depenses.csv

courses           107,70 €
logement           61,15 €
loisirs            57,50 €
transport          88,80 €
--------------------------
Total             315,15 €

Plus grosse dépense : undefined (88,80 €)

Relisez la dernière ligne. Le programme fonctionne, ne plante pas, et affiche undefined à la place du nom de la dépense : la propriété s'appelle intitule dans lecture.js et libelle dans index.js. C'est le bug JavaScript par excellence, celui qui ne fait aucun bruit. Et si on lance le programme sur un fichier qui n'existe pas, c'est l'inverse, un plantage brutal : TypeError: Cannot read properties of undefined (reading 'length'), parce que lireDepenses a renvoyé undefined et que personne ne l'a vérifié. Gardez ces deux défauts en tête : la question de tout l'article est de savoir à quelle étape le compilateur les attrape.

Étape 1 : installer TypeScript sans qu'il se plaigne de rien

Deux paquets de développement, et un fichier de configuration. Le second paquet, @types/node, contient les déclarations de types de Node.js lui-même (process, node:fs, etc.) : TypeScript ne les connaît pas de naissance.

npm install -D typescript @types/node
{
  "compilerOptions": {
    "module": "nodenext",
    "types": ["node"],
    "allowJs": true,
    "checkJs": false,
    "noEmit": true,
    "strict": false
  },
  "include": ["src"]
}

Chaque ligne a un rôle précis dans une migration. allowJs autorise le compilateur à lire des fichiers .js ; checkJs: false lui interdit encore de les critiquer ; noEmit l'empêche d'écrire quoi que ce soit (le programme continue de tourner avec Node sur les sources, comme avant) ; module: "nodenext" lui fait appliquer les règles de modules de Node : c'est alors le "type": "module" du package.json qui décide que les fichiers .js et .ts sont des modules ES (sans lui, le même réglage produirait du CommonJS). Les deux lignes restantes, types et strict, méritent une explication, parce que leurs valeurs par défaut ont changé depuis TypeScript 6.0.

Depuis TypeScript 6.0, publié le 23 mars 2026, les valeurs par défaut ont changé : strict vaut désormais true quand on ne l'écrit pas, et types vaut [] au lieu d'inclure automatiquement tous les paquets @types installés (les notes de version le formulent ainsi : « the default types value will be [] »). TypeScript 7.0, sorti le 8 juillet 2026, reprend ces défauts sur un compilateur porté en Go, annoncé dix fois plus rapide. Conséquence pour une migration : il faut écrire "strict": false explicitement au départ, et déclarer "types": ["node"] à la main. J'ai mesuré ce que donne l'oubli du premier un peu plus loin ; l'oubli du second, tout de suite.

Avec cette configuration, npx tsc se termine sans un mot, code de sortie 0. Zéro erreur : le compilateur a lu les quatre fichiers et n'a rien eu le droit de dire. C'est exactement le but de l'étape, s'assurer que l'installation tient avant d'ouvrir les vannes.

Étape 2 : laisser le compilateur lire le JavaScript

On passe checkJs à true, sans renommer un seul fichier. Première mesure, avec un tsconfig.json dans lequel j'avais volontairement omis la ligne types, pour voir :

src/index.js(5,16): error TS2591: Cannot find name 'process'. Do you need to install type definitions for node? Try `npm i --save-dev @types/node` and then add 'node' to the types field in your tsconfig.
src/index.js(19,89): error TS2554: Expected 0-1 arguments, but got 2.
src/lecture.js(1,42): error TS2591: Cannot find name 'node:fs'. Do you need to install type definitions for node? Try `npm i --save-dev @types/node` and then add 'node' to the types field in your tsconfig.

Trois erreurs. Les deux TS2591 ne visent pas le programme mais la configuration, et elles ont raison : @types/node était bien installé, mais pas déclaré dans types, donc pas inclus, et le message le dit d'ailleurs en toutes lettres à la fin (« add 'node' to the types field »). La ligne remise, il ne reste que TS2554, la deuxième ligne affichée, et celle-là est un vrai bug : à la ligne 19 d'index.js, formaterEuros(grosse.montant, "EUR") passe deux arguments à une fonction qui n'en prend qu'un. En JavaScript, l'argument en trop est ignoré en silence ; c'était un reste d'une ancienne signature. Le compilateur l'a vu sans qu'on ait écrit le moindre type, simplement en lisant la déclaration de la fonction dans format.js.

Notez la formulation « Expected 0-1 arguments » : dans un fichier .js, TypeScript considère comme optionnels les paramètres qu'aucun commentaire JSDoc ne documente, parce qu'il ne peut pas savoir si un appel sans argument est volontaire (un /** @param {number} montant */ suffirait à le rendre obligatoire). Nous verrons le même message changer d'avis après le renommage. Notez aussi ce qu'il n'a pas vu : le grosse.libelle qui affiche undefined. Dans un fichier JavaScript, un paramètre de fonction dont rien n'indique le type, ni valeur par défaut, ni contexte d'appel, ni JSDoc, est de type any ; plusGrosseDepense(depenses) renvoie donc any, et sur any toutes les propriétés existent. Il est possible d'aider le compilateur dès cette étape avec des commentaires JSDoc (/** @param {Depense[]} depenses */), une technique précieuse sur une grosse base de code qu'on ne peut pas renommer d'un coup ; sur quatre fichiers, autant passer directement à la vraie syntaxe.

Étape 3 : renommer les fichiers, les feuilles d'abord

La correction du "EUR" faite, le compilateur revient à zéro erreur, et on peut renommer. Une précision d'abord, mesurée : l'ordre du renommage ne change rien à ce que dit le compilateur à cette étape. Renommer index.js en premier, les feuilles encore en .js, donne zéro erreur, exactement comme dans l'autre sens, parce que sous allowJs le compilateur infère déjà ce qu'il peut d'un module .js, ses types de retour notamment, et que strict est encore à false. L'ordre prépare en réalité l'étape 5, celle où l'on écrit les types : les types posés dans une feuille remontent vers les fichiers qui l'importent, alors que les paramètres non annotés d'une feuille restent any pour tout le monde. Typer les modules qui n'importent rien du projet, les « feuilles », avant le point d'entrée garantit donc que chaque erreur qui remonte dans index.ts est une vraie exigence de type, et non une annotation posée contre un any qu'il faudrait revoir ensuite. Autant renommer dans cet ordre dès maintenant pour l'avoir en tête.

index.js 4 format.js 1 lecture.js 2 calcul.js 3 les feuilles d'abord, le point d'entrée en dernier
Les flèches suivent les import. On renomme puis on type en remontant le graphe, pour que chaque fichier converti trouve déjà de vrais types en face de lui.

Premier renommage, format.js en format.ts, sans changer une lettre du contenu : npx tsc répond zéro erreur. Puis les trois autres d'un coup : zéro erreur encore. Deux détails à connaître ici. D'abord, les instructions import { … } from "./calcul.js" d'index.ts gardent leur extension .js alors que le fichier s'appelle désormais calcul.ts : c'est voulu, TypeScript sait que ./calcul.js désignera le fichier compilé, et le fait correspondre à la source .ts. J'ai mesuré ce qui arrive si on « corrige » en retirant l'extension :

src/index.ts(2,68): error TS2835: Relative import paths need explicit file extensions in ECMAScript imports when '--moduleResolution' is 'node16' or 'nodenext'. Did you mean './calcul.js'?

Ensuite, si vous laissez traîner l'erreur d'arité de l'étape 2 jusqu'ici, son message devient « Expected 1 arguments, but got 2 » : dans un fichier .ts, un paramètre sans point d'interrogation ni valeur par défaut est obligatoire (le largeur = 14 de formaterLigne, lui, reste facultatif), et le compilateur cesse de ménager les appels sans argument. Le renommage ne fait pas qu'autoriser la syntaxe TypeScript, il durcit la lecture du code existant.

Étape 4 : activer strict et lire les 11 erreurs

Tout est en .ts, tout compile, et pourtant rien n'a encore été vérifié pour de bon : strict est toujours à false. On le passe à true, et le compilateur produit d'un coup 11 erreurs, réparties en trois familles seulement (lignes de détail indentées omises) :

src/calcul.ts(1,30): error TS7006: Parameter 'depenses' implicitly has an 'any' type.
src/calcul.ts(9,35): error TS7006: Parameter 'depenses' implicitly has an 'any' type.
src/calcul.ts(12,9): error TS7053: Element implicitly has an 'any' type because expression of type 'any' can't be used to index type '{}'.
src/calcul.ts(12,38): error TS7053: Element implicitly has an 'any' type because expression of type 'any' can't be used to index type '{}'.
src/calcul.ts(17,35): error TS7006: Parameter 'depenses' implicitly has an 'any' type.
src/format.ts(3,31): error TS7006: Parameter 'montant' implicitly has an 'any' type.
src/format.ts(7,31): error TS7006: Parameter 'libelle' implicitly has an 'any' type.
src/format.ts(7,40): error TS7006: Parameter 'montant' implicitly has an 'any' type.
src/index.ts(8,16): error TS18048: 'depenses' is possibly 'undefined'.
src/index.ts(12,42): error TS7053: Element implicitly has an 'any' type because expression of type 'string' can't be used to index type '{}'.
src/lecture.ts(3,30): error TS7006: Parameter 'chemin' implicitly has an 'any' type.

Sept TS7006 : ce sont les sept paramètres de fonction sans annotation du projet, que le compilateur refuse désormais de deviner. Deux paramètres y échappent, et la leçon est utile : largeur = 14, dont la valeur par défaut donne le type, et le (ligne) => passé à map, typé par son contexte d'appel, n'ont pas besoin d'annotation. C'est la famille la plus nombreuse et la moins grave, chaque ligne se règle en écrivant le type du paramètre. Trois TS7053 : l'objet totaux déclaré par const totaux = {} est de type {}, un type qui ne déclare aucune propriété ni signature d'index, et les trois accès dynamiques totaux[…] du projet (deux dans calcul.ts, un dans index.ts) sont refusés. Et une seule TS18048, la plus intéressante : « depenses is possibly undefined », à la ligne 8 d'index.ts, exactement l'endroit où le programme plantait sur un fichier absent. Le compilateur a suivi le return; de lireDepenses et en a conclu que la fonction pouvait rendre undefined. Voilà notre deuxième défaut du départ, trouvé.

Ce compte de 11 dépend d'un détail qu'il faut avoir en tête avec TypeScript 7 : comme strict est vrai par défaut, un tsconfig.json sans la ligne "strict": false aurait produit ces erreurs dès l'étape 2, sur les fichiers .js. Je l'ai mesuré : 12 erreurs (les 11 ci-dessus plus l'arité), sur un projet qu'on n'avait pas encore commencé à toucher. C'est décourageant et inutile ; l'ordre « d'abord lire, ensuite exiger » ne tient que si on désactive strict explicitement au début.

Étape 5 : typer les modules, et laisser les erreurs remonter vers index.ts

La méthode est la même que pour le renommage : on type les feuilles, et on regarde ce que ça révèle dans le point d'entrée. Le contrat de données d'abord, dans lecture.ts, puisque c'est là que les dépenses naissent :

// src/lecture.ts
import { existsSync, readFileSync } from "node:fs";

export interface Depense {
    date: string;
    categorie: string;
    intitule: string;
    montant: number;
}

export function lireDepenses(chemin: string) {
    if (!existsSync(chemin)) {
        console.error(`Fichier introuvable : ${chemin}`);
        return;
    }
    const lignes = readFileSync(chemin, "utf8").trim().split("\n").slice(1);
    return lignes.map((ligne): Depense => {
        const [date, categorie, intitule, montant] = ligne.split(";");
        return { date, categorie, intitule, montant: Number(montant) };
    });
}

Puis calcul.ts, où les trois paramètres deviennent depenses: Depense[], où totaux est déclaré Record<string, number> (un objet dont les clés sont des chaînes et les valeurs des nombres ; Record est un des types génériques fournis par TypeScript, que le guide des génériques détaille), et où let max = null devient let max: Depense | null = null. Ce dernier point mérite une pause : sans strict, un let max = null est silencieusement typé any ; avec, le compilateur suit les affectations de la boucle et en déduit lui-même Depense | null, ce qui compile sans annotation. L'écrire noir sur blanc rend le contrat visible à la lecture et évite qu'une affectation imprévue élargisse le type en silence. Enfin format.ts, deux annotations montant: number et une libelle: string. À ce stade, index.ts n'a pas été touché, et le compilateur le passe au crible (lignes de détail indentées omises) :

src/index.ts(8,16): error TS18048: 'depenses' is possibly 'undefined'.
src/index.ts(10,34): error TS2345: Argument of type 'Depense[] | undefined' is not assignable to parameter of type 'Depense[]'.
src/index.ts(16,49): error TS2345: Argument of type 'Depense[] | undefined' is not assignable to parameter of type 'Depense[]'.
src/index.ts(18,34): error TS2345: Argument of type 'Depense[] | undefined' is not assignable to parameter of type 'Depense[]'.
src/index.ts(19,40): error TS18047: 'grosse' is possibly 'null'.
src/index.ts(19,47): error TS2339: Property 'libelle' does not exist on type 'Depense'.
src/index.ts(19,73): error TS18047: 'grosse' is possibly 'null'.

Sept erreurs, toutes dans le fichier qu'on n'a pas modifié, et parmi elles, ligne 19, colonne 47 : « Property libelle does not exist on type Depense ». Le undefined de la toute première exécution vient d'être attrapé, à la cinquième étape, au moment précis où le type de retour de plusGrosseDepense a cessé d'être any. C'est la leçon centrale de cette migration : le compilateur ne trouve un bug que là où un vrai type a remplacé any, et chaque module typé fait remonter ses exigences vers ceux qui l'utilisent. Les six autres erreurs disent deux choses : les dépenses peuvent manquer, la plus grosse dépense peut être null (sur un fichier vide), et le code doit choisir quoi faire dans ces cas. Voici index.ts corrigé :

// src/index.ts
import { lireDepenses } from "./lecture.js";
import { totalGeneral, totalParCategorie, plusGrosseDepense } from "./calcul.js";
import { formaterEuros, formaterLigne } from "./format.js";

const chemin = process.argv[2] || "donnees/depenses.csv";
const depenses = lireDepenses(chemin);
if (!depenses) {
    process.exit(1);
}

console.log(`${depenses.length} dépenses lues dans ${chemin}\n`);

const totaux = totalParCategorie(depenses);
for (const categorie of Object.keys(totaux).sort()) {
    console.log(formaterLigne(categorie, totaux[categorie]));
}

console.log("-".repeat(26));
console.log(formaterLigne("Total", totalGeneral(depenses)));

const grosse = plusGrosseDepense(depenses);
if (grosse) {
    console.log(`\nPlus grosse dépense : ${grosse.intitule} (${formaterEuros(grosse.montant)})`);
}

Deux gardes et un nom de propriété corrigé. Le if (!depenses) process.exit(1) suffit à faire disparaître les quatre erreurs sur depenses : le compilateur sait que process.exit ne revient jamais (son type de retour est never), et il en déduit qu'après le bloc, depenses est forcément un tableau. Zéro erreur.

Étape 6 : compiler, exécuter, comparer

Jusqu'ici noEmit empêchait toute écriture. On le retire, on ajoute "outDir": "dist" et "rootDir": "src". Ce second réglage n'est plus facultatif : sans lui, TypeScript 7 signale l'erreur TS5011: The common source directory of 'tsconfig.json' is './src'. The 'rootDir' setting must be explicitly set to this or another path to adjust your output's file layout. et sort avec le code 2 ; il émet tout de même les fichiers, mais dans dist/src/, et node dist/index.js ne trouve alors rien. Mesuré en l'omettant. Une fois la ligne posée, npx tsc produit quatre fichiers .js dans dist/. Le programme compilé, lancé sur les mêmes données :

6 dépenses lues dans donnees/depenses.csv

courses           107,70 €
logement           61,15 €
loisirs            57,50 €
transport          88,80 €
--------------------------
Total             315,15 €

Plus grosse dépense : Pass Navigo (88,80 €)

Même sortie que le JavaScript de départ, à une ligne près, la seule qui était fausse. Sur le fichier absent, le programme affiche « Fichier introuvable » et s'arrête avec le code 1, au lieu de la TypeError du début. Le projet est passé de 66 à 80 lignes : sept pour l'interface et sa ligne vide, cinq pour les deux gardes, deux pour importer le type dans calcul.ts ; les annotations elles-mêmes n'ajoutent aucune ligne. Et sur un fichier CSV vide, testé aussi, le programme affiche un total de 0,00 € et saute simplement la ligne de la plus grosse dépense, grâce au if (grosse). Le tableau ci-dessous résume ce que le compilateur a dit à chaque cran, sur ce même code.

ÉtapeRéglageErreursRévélé
1allowJs0Rien à dire
2checkJs3, puis 12 défauts de config, 1 argument en trop
3vers .ts0Rien encore
4strict117 any implicites, 3 accès indexés refusés, 1 undefined
5typage7libelle, null, undefined
6gardes0Sortie juste
0 allowJs 1 checkJs 0 .ts 11 strict 7 typés 0 corrigé erreurs comptées par npx tsc, TypeScript 7.0.2
Le pic n'est pas à l'installation ni au renommage, mais au passage de strict : c'est là qu'il faut prévoir du temps.

Ce qui change quand le projet fait 20 000 lignes et pas 66

La méthode ne change pas, la durée si, et trois règles permettent de la tenir. La première : ne jamais tout renommer d'un coup. Sur ce petit projet j'ai renommé trois fichiers en une fois parce que le compilateur avait déjà dit zéro erreur ; sur un vrai projet, on renomme un module, on le type, on fait passer npx tsc au vert, on livre, et on recommence la semaine suivante. Le projet reste déployable à chaque instant, avec un mélange de .js et de .ts qui n'a rien de honteux.

La deuxième : quand strict fait apparaître des centaines d'erreurs et pas onze, activer ses composants un par un. strict est un raccourci pour une famille d'options ; noImplicitAny seul règle la famille TS7006, puis strictNullChecks seul règle les TS18047 et TS18048, et le reste suit. Le handbook officiel recommande la même chose, avec une nuance utile : si vous savez que vous finirez en strict, autant activer noImplicitAny avant de commencer à modifier les fichiers, pour ne pas typer deux fois.

La troisième concerne any. Sur un gros projet, il est légitime d'écrire any quelques semaines, comme échafaudage, pour faire passer un module au vert sans typer tout ce qu'il touche ; l'important est de le savoir temporaire et de le chasser ensuite, module après module. Ce que la migration ci-dessus montre sans ambiguïté, c'est que chaque any qui reste est un endroit où le compilateur ne verra rien : notre libelle fantôme a survécu à quatre étapes pour cette seule raison. Si la frontière entre any et unknown n'est pas encore nette pour vous, la comparaison entre unknown et any reprend cette question de la contagion avec d'autres mesures.

Un dernier mot sur le rythme. Cette migration de 66 lignes a tenu en une quinzaine d'exécutions de npx tsc et 14 lignes ajoutées, et elle a rendu deux bugs réels dont un invisible. Je ne peux pas promettre le même rapport sur un projet plus gros, seulement la même mécanique : les types qu'on ajoute se comptent en lignes, les erreurs qu'on lit racontent l'histoire du code, et c'est là que la migration paie. Si vous cherchez un cobaye de taille raisonnable pour vous entraîner, le jeu Snake en JavaScript de ce blog est un candidat idéal : un seul fichier, quelques fonctions, et un getContext("2d") qui peut renvoyer null, ce que le compilateur ne manquera pas de vous rappeler.