Accueil / Articles / Le support natif de TypeScript par Node : 7 cas concrets de dysfonctionnements et leurs solutions.

Le support natif de TypeScript par Node : 7 cas concrets de dysfonctionnements et leurs solutions.

Découvrez quels fonctionnalités de TypeScript sont silencieusement compromises par le débogage des types intégré à Node en production, ainsi que les configurations exactes qui permettent de corriger ces problèmes, testées sur Node 22.18+ et 24.x LTS.

2809 mots

Une équipe en train de migrer un petit service Express a décidé d’abandonner ts-node et d’exécuter l’application directement avec node file.ts en environnement de staging. Ce changement semblait fonctionner correctement lors des tests locaux, mais dès le jour de travail suivant, le pipeline CI commençait à générer des builds échoués ; certains développeurs rencontraient l’erreur ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX dans leurs terminaux. De plus, un correctif d’urgence pour l’environnement de production a été déployé sans aucun contrôle de types, car cette protection avait discrètement disparu.

La gestion native de TypeScript par Node, via le suppression des types, constitue une fonctionnalité solide. Cependant, elle couvre un éventail plus restreint de fonctionnalités de TypeScript que ce à quoi la plupart des gens s’attendent, et ces lacunes ne deviennent évidentes que lorsqu’on les rencontre dans la pratique. Ci-dessous sont présentés sept problèmes survenus lors d’une migration réelle, ainsi que les erreurs concrètes générées et les correctifs testés avec Node 22.18+ et 24.x LTS.

La promesse contre la réalité

Node exécute TypeScript en supprimant les annotations de type au moment de l’exécution à l’aide d’une version intégrée de swc. Il n’appelle jamais le compilateur TypeScript. Aucune phase tsc n’est impliquée. Par conséquent, il n’y a ni vérification de types, ni transformation syntaxique pour des cibles plus anciennes, ni résolution d’alias de chemins, ni prise en charge des fichiers .tsx, ni support pour les décorateurs, ni enums, et aucune génération de code au moment de l’exécution pour les noms d’espace.

Exécuter node file.ts fonctionne, et c’est bien une capacité réelle, mais cela ne représente que la fonctionnalité minimale, et non l’ensemble complet des fonctionnalités de TypeScript.

1. Mes imports relatifs renvoient silencieusement 404 en production

Le symptôme. Tout fonctionnait localement. Mais une fois déployé, l’application échouait au démarrage avec une erreur du type suivante :

Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/srv/app/dist/utils/hash.js'
imported from /srv/app/dist/server.js

Ce qui s’est passé. Le retrait des types ne supprime que les annotations ; il laisse tous les autres tokens du fichier inchangés, y compris les chemins d’importation. Ainsi, un fichier source comme celui-ci :

// src/server.ts
import { hashToken } from "./utils/hash.js";

est transmis tel quel, sans modification. Lors du développement local, node --experimental-strip-types était suffisamment intelligent pour résoudre ./utils/hash.ts même si l’extension indiquait .js. Mais le processus de compilation (compilation avec tsc vers un dossier dist) a conservé l’extension littérale .js dans la chaîne, et il n’y avait aucun fichier .js correspondant à l’intérieur de dist/utils/ — seuls des fichiers sources .ts avaient été compilés ailleurs.

La solution. Deux modifications distinctes doivent être apportées en même temps.

Tout d’abord, indiquez l’extension d’import correspondant à ce qui se trouve réellement sur le disque — c’est-à-dire .ts, et non .js:

// src/server.ts
import { hashToken } from "./utils/hash.ts";

Ensuite, informez le compilateur que c’est intentionnel et laissez-le transformer l’extension lors de la génération du code :

{
  "compilerOptions": {
    "noEmit": true,
    "allowImportingTsExtensions": true,
    "rewriteRelativeImportExtensions": true,
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "target": "esnext",
    "verbatimModuleSyntax": true,
    "erasableSyntaxOnly": true
  }
}

La configuration clé est rewriteRelativeImportExtensions: true, qui convertit ./utils/hash.ts en ./utils/hash.js lorsque tsc génère le code, de sorte que le JavaScript compilé fonctionne toujours correctement pour tous ceux qui l’utilisent par la suite. noEmit: true n’est pas optionnel ici — sans lui, allowImportingTsExtensions provoque une erreur TS5096. Consultez la documentation de TypeScript pour plus de détails.

2. La moitié de mon codebase utilisait une « syntaxe non prise en charge »

Le symptôme. Un développeur a rencontré cette erreur lors de son premier commit après le changement :

TypeError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript enum declarations are
not supported by Node's type stripping. Convert enums to objects with `as const`
or use a transformer.

Un autre a fait face à un problème similaire avec le constructeur d’une classe :

TypeError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript parameter properties
are not supported. Use an explicit field declaration instead.

Ce qui s’est passé. Le moteur de suppression des types de Node ne prend en charge délibérément qu’une catégorie limitée de syntaxe : des constructions qui peuvent être supprimées entièrement sans modifier le comportement en temps de exécution, dites syntaxes « érasables ». Toute fonctionnalité de TypeScript qui génère réellement de la logique JavaScript en temps de exécution est rejetée avec une exception en temps de exécution au lieu d’être transformée.

La liste complète des constructions non prises en charge, tirée de la documentation officielle de Node TypeScript, comprend : les déclarations enum, qui doivent être transformées en union de chaînes ou en objet à l’aide de as const ; les blocs namespace contenant de la logique en temps d’exécution, dont les valeurs exportées doivent être déplacées dans des exports de module simples (les namespaces contenant uniquement des types restent acceptés) ; les propriétés de paramètre dans les constructeurs (comme constructor(public x: number)), qui exigent plutôt une déclaration explicite de champ ; les alias d’import, qui doivent être renommés au moment de l’importation ; les décorateurs, qui échouent au niveau du parseur et ne sont pas polyfillés ; ainsi que les fichiers .tsx, car seules les extensions .ts, .mts et .cts sont reconnues.

La solution. Voici comment le cas enum est résolu :

// before ;  dies at runtime
enum Role { Admin = "admin", User = "user" }

// after ;  works under type stripping AND in tsc
const Role = {
  Admin: "admin",
  User: "user",
} as const;

type Role = (typeof Role)[keyof typeof Role];

Pour les décorateurs, l’approche la plus sûre est d’attendre que le parseur de Node prenne en charge nativement la proposition de décorateur TC39 avant de les adopter, ou d’utiliser un transformateur comme swc ou tsc dans le pipeline spécifiquement pour les fichiers qui en dépendent. Activer "erasableSyntaxOnly": true dans tsconfig.json est également utile — cela fait en sorte que le compilateur indique directement dans votre éditeur les syntaxes non prises en charge avant même qu’elles n’atteignent le temps de exécution.

3. La vérification des types ne se fait pas en silence

Le symptôme. Un gestionnaire de production a accepté une valeur null là où une valeur de type string était attendue, ce qui a provoqué une panne lors de l’appel à la méthode .length sur cette valeur. Le test unitaire correspondant s’est exécuté sans problème. La variable était déclarée de type string, mais sa valeur réelle était null, et le fichier node file.ts les a tous deux exécutés sans objection.

app.post("/webhook", (req, res) => {
  const body: string = req.body.payload; // null sneaks in, no one notices
  console.log(body.length);
});

Ce qui s’est passé. Le débogage de type fonctionne uniquement au niveau textuel — il ne consulte jamais le vérificateur de types. Aucun élément du chemin d’exécution de node ne vérifie que les valeurs circulant dans votre code correspondent à leurs types déclarés.

C’est sans doute le mode de défaillance silencieuse le plus risqué introduit par l’abandon de ts-node. Un exécution réussie de node file.ts ne dit rien sur le fait que le code soit correctement typé.

La solution. Réintroduisez la vérification de types en tant qu’étape distincte et explicite.

// package.json
{
  "scripts": {
    "dev": "node --watch src/server.ts",
    "typecheck": "tsc --noEmit",
    "lint": "biome check .",
    "ci": "npm run typecheck && npm run lint"
  }
}

Exécutez tsc --noEmit dans le cadre du CI pour chaque demande de fusion, et envisagez de l’intégrer dans des hooks pré-commit si cela convient à votre flux de travail. Le runtime natif exécute le code ; tsc est l’outil chargé de détecter les erreurs de type. Ces deux fonctions sont désormais complètement dissociées, et cette séparation fait partie intégrante de la conception de Node.

Il est également utile d’activer "erasableSyntaxOnly": true ainsi que "verbatimModuleSyntax": true dans tsconfig.json. La première configuration fait en sorte que tsc rejette tout ce que le nettoyage des types ne peut pas gérer — détectant ainsi les décorateurs ou les enums au moment de la compilation plutôt qu’à l’exécution. La seconde impose l’utilisation explicite des instructions import type afin que les importations uniquement basées sur les types ne laissent pas derrière elles des instructions d’importation à exécution non désirées.

4. Mes alias de chemins @/utils/* ont cessé de fonctionner

Le symptôme. Une erreur familière :

Error [ERR_MODULE_NOT_FOUND]: Cannot find module '@/utils/logger'
imported from /srv/app/src/server.ts

Ce qui se passait. Le champ paths dans tsconfig.json n’est qu’une convenance de compilation pour TypeScript — Node lui-même ne l’a jamais compris. Des outils comme ts-node et tsx le respectaient car ils implémentaient leur propre logique de résolution de modules par-dessus Node. Le stripping des types natifs ne fait pas cela ; il transfère directement la résolution au chargeur de Node.

// tsconfig.json ;  this never worked at runtime, it only worked in your editor
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": { "@/utils/*": ["src/utils/*"] }
  }
}

La solution. Il existe trois approches légitimes, en fonction de la manière dont votre application est déployée.

Option A : utiliser les importations de sous-chemin intégrées à Node. Supprimez complètement les chemins de tsconfig et déclarez plutôt la correspondance dans package.json :

{
  "imports": {
    "#utils/*": "./src/utils/*"
  }
}
// src/server.ts
import { logger } from "#utils/logger.ts";

Cela fonctionne correctement sous node, sous tsc --noEmit et sous vitest, sans aucune configuration supplémentaire requise. Le # en tête est une convention propre à Node indiquant que « c’est un alias interne, pas un package publié ». Migrer consiste simplement à effectuer une recherche et remplacement dans l’ensemble du projet, en remplaçant @/utils/ par #utils/.

Option B : recourir aux importations relatives et accepter les chaînes ../. C’est fastidieux, mais il n’y a pas d’outils cachés impliqués.

Option C : conserver un transformateur de réécriture des chemins. Des outils tels que tsc-alias réécrivent le JavaScript généré après compilation, ou vous pouvez laisser tsx/swc résoudre les alias en temps de exécution. Cette approche réintroduit l’étape de compilation que vous essayiez d’éliminer, ce qui diminue fortement les avantages du TypeScript natif. Ce n’est pas une voie à suivre en 2026.

5. L’interopérabilité entre CommonJS et ESM m’a pris par surprise

Le symptôme. Une appelation require("openai") qui fonctionnait toujours a soudainement généré une erreur :

Error [ERR_REQUIRE_ESM]: require() of ES Module ... openai ... not supported.

Ou, dans le sens inverse, une importation de dossier sans extension a provoqué un problème :

Error [ERR_UNSUPPORTED_DIR_IMPORT]: Directory import ... is not supported
under ESM

Ce qui se passait. Auparavant, votre fichier source était compilé par tsc en un fichier .js dans dist/, et require() fonctionnait comme prévu. Avec l’exécution native de TypeScript, le fichier que Node charge réellement est le fichier .ts lui-même, et Node détermine s’il doit le traiter comme CommonJS ou ESM en se basant sur le champ "type" du package.json le plus proche. Si ce champ indique "module", tous les fichiers .ts dans le champ d’application sont considérés comme ESM, ce qui fait échouer toute appel restant à require(). Si ce champ est manquant (ce qui correspond par défaut à CommonJS), le problème inverse apparaît : l’importation d’une dépendance réservée à ESM échoue.

La solution. Choisissez un système de modules et imposez-le dans tout le projet.

Si vous commencez à zéro, définissez "type": "module" dans package.json, écrivez tout en format ESM, et réservez les extensions .mts/.cts aux fichiers qui ont réellement besoin de l’autre format.

// package.json
{
  "type": "module",
  "engines": { "node": ">=22.18.0" }
}

// src/server.ts
import { readFile } from "node:fs/promises";   // ESM, native
import OpenAI from "openai";                    // pure ESM upstream
const openai = new OpenAI();

Pour une base de code CommonJS existante, conservez "type": "commonjs" (ou omettez ce champ) — et évitez de tenter d’importer un package purement ESM depuis du code CommonJS sans passer par une importation dynamique import(). Node 22.12+ prend en charge une fonction require(esm) stable, mais compter sur elle expose toujours à des risques liés aux deux formats de packages et rend votre processus de compilation fragile. Les options plus sûres sont de convertir le fichier appelant en format ESM, ou d’encapsuler la dépendance dans une importation dynamique à l’intérieur d’une fonction async.

Il y a un deuxième piège ici : les imports de dossiers. Avec ESM, écrire import x from "./folder" ne résoudra pas automatiquement en ./folder/index.ts comme c’était le cas auparavant. Vous devez spécifier explicitement le nom du fichier :

// bad
import { routes } from "./routes";

// good
import { routes } from "./routes/index.ts";

6. Le mode surveillance et le rechargement en temps réel ont reculé

Les symptômes. Après être passé de tsx watch src/server.ts à node --watch src/server.ts, plusieurs choses se sont détériorées :

  • Vitesse de redémarrage — node --watch fonctionne, mais est nettement moins rapide.
  • Rechargement fiable lorsqu’un changement a lieu dans un fichier importé en dehors de la racine du projet.
  • La possibilité de déclencher un redémarrage manuel via SIGUSR2.
  • Les sorties colorées ainsi que l’invite amicale « appuyez sur R pour redémarrer ».
  • Exclusions par défaut pertinentes pour les fichiers node_modules, dist et .test.ts.
  • Qu’est-ce qui se passait ? node --watch est l’outil de surveillance des fichiers intégré depuis longtemps à Node. Il fonctionne désormais avec les fichiers .ts grâce au suppression des types, mais il n’a jamais été conçu pour remplacer complètement des outils spécialisés tels que tsx watch ou nodemon — il s’agit plutôt d’une fonctionnalité de base.

    La solution. Utilisez node --watch lorsque vous avez simplement besoin d’un redémarrage forcé en cas de modification d’un seul script. Pour un véritable serveur avec une chaîne d’importations et un vrai cycle de développement ou de test, privilégiez tsx watch. Il n’y a rien d’anormal à continuer d’utiliser tsx comme outil de développement, même après avoir déplacé l’exécution en production vers du TypeScript natif.

    // package.json ;  pragmatic split
    {
      "scripts": {
        "dev": "tsx watch src/server.ts",
        "start": "node --enable-source-maps src/server.ts",
        "start:native": "node src/server.ts"
      }
    }
    

    tsx s’exécute via esbuild et est considérablement plus rapide que tscenviron 20 à 30 fois plus rapide selon les propres benchmarks du projet — il comprend nativement les alias de chemins, et son comportement est similaire à celui de node --watch si le mode surveillance avait reçu plus d’attention de la part des développeurs.

    7. Omettre l’étape de compilation ne fait que déplacer le problème

    Les symptômes. Après avoir annoncé que l’équipe abandonnerait tsc au profit d’une exécution native, un certain nombre de problèmes sont apparus presque immédiatement :

    • Le SDK publié sur npm nécessitait des fichiers de déclaration .d.ts pour les utilisateurs finaux. L’exécution native de TypeScript ne génère pas ces fichiers.
    • La cible de déploiement Lambda attendait un format CommonJS, mais le code était écrit en ESM.
  • .ts étaient désormais envoyés au lieu des résultats de compilation.
  • Ce qui s’est passé. La suppression des types a lieu en temps de exécution, et non lors de la compilation — c’est justement l’objectif de cette fonctionnalité. Mais dès que votre code doit être exécuté ailleurs que sur « Node 22 ou version ultérieure, en exécution directement depuis votre répertoire », vous avez à nouveau besoin d’une étape de compilation. Elle n’a pas disparu ; elle s’est simplement déplacée dans une autre partie du pipeline.

    La solution. Soyez précis quant au type de fichiers que vous envoyez réellement.

    Si vous développez une application — un service que vous déployez et exécutez vous-même — le TypeScript natif représente une véritable amélioration. Il n’y a pas de phase de compilation, les démarrages sont plus rapides, et le Dockerfile devient plus simple puisque vous pouvez simplement utiliser COPY src ./src au lieu de gérer un dossier dist/.

    # Dockerfile
    FROM node:24-slim
    WORKDIR /app
    COPY package.json package-lock.json ./
    RUN npm ci --omit=dev
    COPY src ./src
    COPY tsconfig.json ./
    CMD ["node", "--enable-source-maps", "src/server.ts"]
    

    Si vous maintenez une bibliothèque destinée à npm, conservez tsc pour l’étape de génération réelle du code. Vous pouvez utiliser le TypeScript natif pendant le développement et les tests, mais le paquet publié doit toujours inclure des fichiers .js compilés ainsi que des fichiers .d.ts.

    // package.json ;  library case
    {
      "scripts": {
        "dev": "node --watch src/index.ts",
        "build": "tsc",
        "test": "node --test --experimental-strip-types test/*.test.ts"
      }
    }
    

    Si votre cible sont des environnements serverless, des environnements d’exécution edge ou des utilisateurs sous Node 20.x, vous avez toujours besoin d’un transpilateur — que ce soit swc ou tsc — configuré pour générer du code que des environnements plus anciens peuvent exécuter. L’étape de compilation que vous pensiez avoir éliminée reste nécessaire dans ce cas.

    Va-t-il vraiment la peine ? Le verdict honnête.

    Le support natif de TypeScript représente peut-être l’amélioration la plus significative pour Node depuis l’arrivée d’async/await — mais ces éloges s’accompagnent d’une réserve importante.

    C’est judicieux de l’adopter si vous :

    • Déployez un service auto-géré sous Node 22.18+ ou la ligne LTS 24.x.
    • Écrivez déjà du TypeScript propre et facile à nettoyer — sans enums, sans décorateurs, uniquement de la syntaxe ESM.
    • Avez un job CI qui exécute tsc --noEmit afin que la vérification des types ne disparaisse pas discrètement de votre flux de travail.
  • Voulez-vous des démarrages en froid plus rapides, des Dockerfiles plus légers, et une dépendance de moins dans node_modules ?
  • Mieux vaut rester avec tsx ou tsc si vous :

    • Publiez une bibliothèque qui doit fonctionner sur des versions anciennes de Node pour vos utilisateurs.
    • Dépendez fortement de NestJS, TypeORM, class-validator ou d’autres outils basés sur des décorateurs expérimentaux.
    • Avez besoin du support .tsx pour les composants React rendus côté serveur.
    • Utilisez des alias de chemin dans tsconfig et n’êtes pas prêts à passer au champ imports.
    • N’avez pas encore la discipline (ou les outils) nécessaires pour éviter que des syntaxes non érasables n’apparaissent dans votre codebase.

    La configuration qui a finalement rendu cela possible :

    // tsconfig.json
    {
      "compilerOptions": {
        "target": "esnext",
        "module": "nodenext",
        "moduleResolution": "nodenext",
        "noEmit": true,
        "allowImportingTsExtensions": true,
        "rewriteRelativeImportExtensions": true,
        "verbatimModuleSyntax": true,
        "erasableSyntaxOnly": true,
        "strict": true,
        "skipLibCheck": true,
        "isolatedModules": true,
        "resolveJsonModule": true
      },
      "include": ["src/**/*"]
    }
    
    // package.json (snippet)
    {
      "type": "module",
      "engines": { "node": ">=22.18.0" },
      "scripts": {
        "dev": "tsx watch src/server.ts",
        "start": "node --enable-source-maps src/server.ts",
        "typecheck": "tsc --noEmit",
        "test": "node --test --experimental-strip-types 'src/**/*.test.ts'",
        "ci": "npm run typecheck && npm test"
      }
    }
    

    C’est toute la configuration. Aucun signe de ts-node. Aucun tsc dans le chemin d’exécution en temps réel. Aucun fichier nodemon.json volumineux. Une seule outil gère le développement, un autre la vérification des types, et un dernier l’environnement de production. Et pour être clair, node_modules reste tout aussi encombré qu’avant — cet aspect particulier de l’écosystème Node n’a pas changé depuis 2009.

    Le véritable avantage ici n’est pas que ts-node soit supprimé de vos dépendances. C’est que vous cessez de croire que sa suppression élimine également l’étape de compilation. L’exécution native de TypeScript constitue un processus de compilation plus compact, plus rapide et plus transparent — mais c’est toujours un processus de compilation. Cette migration ne consiste pas à passer d’un « processus de compilation » à « aucun processus de compilation ». C’est plutôt le passage d’une étape de compilation invisible à une étape que l’on comprend réellement.

    Lectures complémentaires

  • Remplacer Jest par l’exécuteur de tests natif de Node dans Node 24 — Une migration réelle montre comment l’exécuteur de tests intégré à Node 24 ainsi que son support natif pour TypeScript réduisent le temps de CI tout en éliminant quatre dépendances.