Exécuter et bundler du TypeScript sur Node
Node.js exécute le TypeScript sans compilation depuis la 22.18. Ce que fait le type stripping, quand un bundle reste utile, et comment le produire avec esbuild.
En 2022, cet article expliquait comment transformer un projet TypeScript en un seul fichier JavaScript avec tsc et Webpack. Il partait d'une phrase qui n'est plus vraie : « Node.js ne comprend pas TypeScript nativement ». Depuis, Node sait lancer un fichier .ts tout seul.
J'ai donc repris l'article. Tu y trouveras ce que Node fait sans outil, les cas où un bundle garde son intérêt, la commande esbuild que j'utiliserais aujourd'hui, et la config Webpack d'origine remise en état si tu en maintiens une.
flowchart TB
subgraph y2022["2022"]
direction LR
A["src/*.ts"] -->|tsc| B["build/*.js"] -->|webpack| C["dist/bundle.js"]
end
subgraph y2026["2026"]
direction LR
D["src/*.ts"] -->|"node, en dev"| E["exécution directe"]
D -->|"esbuild, pour livrer"| F["dist/bundle.js"]
end
y2022 ~~~ y2026Node exécute le TypeScript sans compilation
Le mécanisme s'appelle le type stripping. Node lit ton fichier .ts, retire les annotations de type, et exécute le JavaScript qui reste. Aucun fichier n'est écrit sur le disque.
L'historique, d'après la doc officielle :
| Version de Node | Ce qui change |
|---|---|
| 22.6 | Arrivée du type stripping, derrière --experimental-strip-types |
| 23.6 et 22.18 | Activé par défaut, plus besoin de flag |
| 24.12 et 25.2 | La fonctionnalité passe en stable |
Prenons deux fichiers, src/greet.ts et src/index.ts :
export function greet(name: string): string {
return `Salut ${name}`;
}import { hostname } from 'node:os';
import { greet } from './greet.ts';
console.log(greet(hostname()));Ajoute "type": "module" dans ton package.json pour dire à Node que le projet utilise les imports ES, puis lance :
node src/index.tsÇa affiche Salut suivi du nom de ta machine. Pour relancer à chaque modification, node --watch src/index.ts suffit. La chaîne tsc --watch plus Webpack plus nodemon de 2022 tient maintenant dans cette commande.
Ce que le type stripping ne fait pas
Node retire les types, il ne les lit pas. Ça a plusieurs conséquences à connaître avant de s'y fier.
Attention
Node ne vérifie pas tes types. Un fichier qui contient const n: number = "pas un nombre" s'exécute sans broncher. La vérification reste le travail de tsc, à lancer dans ton éditeur et dans ta CI.
Les extensions sont obligatoires dans les imports. Tu écris ./greet.ts, pas ./greet. Sans l'extension, Node répond ERR_MODULE_NOT_FOUND.
Le tsconfig.json est ignoré à l'exécution. Les alias de paths comme @/greet.ts ne sont donc pas résolus.
La syntaxe qui génère du code n'est pas supportée. Un enum, un namespace avec du code ou une propriété déclarée dans les paramètres d'un constructeur ne sont pas de simples annotations : il faudrait les transformer en JavaScript. Node s'arrête avec cette erreur :
SyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript enum is not supported in strip-only mode
Enfin, Node refuse les fichiers .ts situés dans node_modules. Une librairie publiée sur npm doit toujours livrer du JavaScript.
Pour que tsc te signale ces cas avant Node, voici un tsconfig.json adapté. Il ne produit aucun fichier, il sert uniquement à vérifier :
{
"compilerOptions": {
"noEmit": true,
"target": "esnext",
"module": "nodenext",
"types": ["node"],
"strict": true,
"erasableSyntaxOnly": true,
"verbatimModuleSyntax": true,
"allowImportingTsExtensions": true
},
"include": ["src"]
}Trois options méritent une explication :
erasableSyntaxOnlyfait échouertscsur unenumou toute syntaxe que Node ne sait pas retirer.verbatimModuleSyntaxt'oblige à écrireimport typepour importer un type. Sans ça, Node chercherait à l'exécution un export qui n'existe pas.allowImportingTsExtensionsautorise les imports en.ts.
npm install -D typescript @types/node
npx tscNote
Sur Node 22 et 24, le flag --experimental-transform-types permettait d'exécuter quand même les enum. Il a été retiré dans Node 26. Mieux vaut ne pas bâtir dessus.
Un bundle reste utile pour livrer
Si Node lance le TypeScript directement, on pourrait copier src/ sur le serveur et s'arrêter là. Pour un script ou un petit outil interne, c'est ce que je ferais.
Un bundle, c'est-à-dire un seul fichier JavaScript qui regroupe tout ton code, garde son intérêt dans quelques cas :
- tu veux un artefact unique à copier dans une image Docker ou sur un serveur ;
- ton code utilise des
enum, des aliaspathsou des décorateurs, que Node ne gère pas seul ; - tu vises une plateforme serverless, où un fichier compact démarre en général plus vite qu'une arborescence de modules ;
- le serveur tourne sur une version de Node antérieure à la 22.18.
esbuild produit le bundle en une commande
esbuild est un bundler écrit en Go. Il lit le TypeScript directement, donc l'étape tsc intermédiaire de 2022 disparaît, et il n'a pas besoin de fichier de configuration.
npm install -D esbuild
npx esbuild src/index.ts --bundle --platform=node --format=esm --packages=external --outfile=dist/bundle.jsChaque option a un rôle :
--bundlesuit les imports depuissrc/index.tset regroupe tout dans un fichier.--platform=nodelaisse les modules intégrés de Node (node:os,node:fs) tranquilles.--format=esmsort des modules ES, cohérent avec le"type": "module"du projet.--packages=externallaisse les dépendances denode_moduleshors du bundle. Tu les installes sur le serveur avecnpm install --omit=dev.--outfiledonne le fichier de sortie.
Sur l'exemple, le bundle est écrit en quelques millisecondes et se lance avec node dist/bundle.js.
Si tu retires --packages=external, esbuild embarque aussi les dépendances. Tu obtiens alors un fichier vraiment autonome, sans npm install côté serveur. Ça fonctionne tant que tes dépendances sont du JavaScript pur. Les modules natifs, ceux qui chargent un binaire .node, doivent rester externes.
Attention
Comme Node, esbuild ne vérifie pas les types. Il produit un bundle même si tsc trouve des erreurs. Garde tsc dans le script de build.
Les scripts du package.json deviennent :
"scripts": {
"dev": "node --watch src/index.ts",
"typecheck": "tsc --noEmit",
"build": "npm run typecheck && esbuild src/index.ts --bundle --platform=node --format=esm --packages=external --outfile=dist/bundle.js",
"start": "node dist/bundle.js"
}Les autres outils selon le besoin
esbuild n'est pas le seul candidat. Voici comment je trancherais :
| Besoin | Outil |
|---|---|
| Lancer un script ou un serveur en dev | node seul |
| Un seul fichier pour un serveur Node | esbuild |
| Publier une librairie npm avec ses types | tsdown |
| Une application front | Vite |
| Un projet qui dépend déjà de loaders et de plugins Webpack | Webpack |
tsdown s'appuie sur Rolldown, un bundler écrit en Rust, et génère les fichiers de déclaration .d.ts dont une librairie a besoin. Vite a lui aussi changé de moteur : la version 8 repose sur Rolldown, alors que les précédentes utilisaient esbuild et Rollup.
Hors de Node, Bun et Deno exécutent le TypeScript nativement depuis leurs débuts, et chacun embarque son propre bundler. Si tu es déjà sur l'un des deux, tu n'as rien à installer.
La config Webpack de 2022, remise à jour
Je garde ici la chaîne d'origine pour ceux qui en maintiennent une. Le principe : tsc compile src/ vers build/, puis Webpack regroupe build/ dans dist/bundle.js.
|-- src/
| |-- index.ts
|-- build/ # JS transpilé par tsc
|-- dist/ # Bundle final
|-- package.json
|-- tsconfig.json
|-- webpack.config.jsCe projet-là reste en CommonJS : pas de "type": "module" dans le package.json, et des imports sans extension (./greet).
npm install -D typescript @types/node webpack webpack-cli webpack-node-externals concurrentlyLe tsconfig.json de 2022 ne passe plus avec TypeScript 7. J'ai dû changer trois choses pour que tsc compile à nouveau :
- retirer
"moduleResolution": "node", option supprimée (erreurTS5108) ; - déclarer
rootDir, quetscne déduit plus tout seul (erreurTS5011) ; - ajouter
"types": ["node"], sans quoitscne trouve plus les types deosoufs.
{
"compilerOptions": {
"module": "commonjs",
"esModuleInterop": true,
"types": ["node"],
"rootDir": "src",
"outDir": "build",
"target": "es2022"
},
"include": ["src"]
}La config Webpack, elle, n'a pas bougé. Webpack ne lit pas le TypeScript ici, il prend le JavaScript déjà compilé dans build/ :
const path = require('path');
const nodeExternals = require('webpack-node-externals');
module.exports = {
target: 'node',
mode: 'development',
externals: [nodeExternals()],
entry: './build/index.js',
output: {
path: path.join(__dirname, 'dist'),
filename: 'bundle.js',
}
}La ligne qui compte, c'est nodeExternals(). Elle joue le même rôle que --packages=external chez esbuild : le bundle ne contient que ton code, et node_modules reste à installer sur le serveur. Sans elle, Webpack essaie d'embarquer toutes les dépendances, et ça finit mal avec les modules natifs.
Le target: 'node' évite que Webpack ne traite fs ou path comme des modules à embarquer pour le navigateur.
Côté scripts, deux watchers tournent en parallèle :
"scripts": {
"watch-compile:server": "tsc --watch",
"watch-bundle:server": "webpack --watch",
"concurrent-watch": "concurrently npm:watch-*"
}Avec npm run concurrent-watch, TypeScript recompile à chaque modification, Webpack voit le JavaScript changer dans build/ et refait le bundle. C'est rustique, mais ça tourne encore avec Webpack 5.
Ce que je ferais sur un nouveau projet
Je commencerais sans bundler : node --watch en développement, tsc --noEmit pour les types. J'ajouterais esbuild le jour où j'ai besoin d'un fichier unique à livrer. Webpack, je le garde pour les projets qui l'ont déjà et qui dépendent de ses plugins. Le migrer coûte du temps, et tant que la chaîne tourne, rien ne presse.
À lire ensuite
MCP pour les devs : relier tes LLM au monde réel
MCP (Model Context Protocol) évite d'écrire un connecteur par client LLM et par source de données. Son architecture, puis un premier serveur MCP en TypeScript.
MCP remote : quand stdio ne suffit plus
Stdio suffit sur ta machine, pas au-delà. Streamable HTTP sans session depuis la spec de juillet 2026, un serveur MCP remote en TypeScript, puis OAuth 2.1.
Agents IA : boucle agentique et pattern ReAct
Construis un agent IA autonome en TypeScript avec Mistral : boucle agentique, pattern ReAct, planification, mémoire et garde-fous. Code complet inclus.