# 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.

Source : https://simotech.dev/blog/executer-bundler-typescript-node

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.

```mermaid
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 ~~~ y2026
```

## Node 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](https://nodejs.org/api/typescript.html) :

| 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` :

```ts title="src/greet.ts"
export function greet(name: string): string {
  return `Salut ${name}`;
}
```

```ts title="src/index.ts"
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 :

```bash
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.

> [!WARNING]
> 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 :

```json title="tsconfig.json"
{
  "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 :

- `erasableSyntaxOnly` fait échouer `tsc` sur un `enum` ou toute syntaxe que Node ne sait pas retirer.
- `verbatimModuleSyntax` t'oblige à écrire `import type` pour importer un type. Sans ça, Node chercherait à l'exécution un export qui n'existe pas.
- `allowImportingTsExtensions` autorise les imports en `.ts`.

```bash
npm install -D typescript @types/node
npx tsc
```

> [!NOTE]
> 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 alias `paths` ou 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](https://esbuild.github.io/) 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.

```bash
npm install -D esbuild
npx esbuild src/index.ts --bundle --platform=node --format=esm --packages=external --outfile=dist/bundle.js
```

Chaque option a un rôle :

- `--bundle` suit les imports depuis `src/index.ts` et regroupe tout dans un fichier.
- `--platform=node` laisse les modules intégrés de Node (`node:os`, `node:fs`) tranquilles.
- `--format=esm` sort des modules ES, cohérent avec le `"type": "module"` du projet.
- `--packages=external` laisse les dépendances de `node_modules` hors du bundle. Tu les installes sur le serveur avec `npm install --omit=dev`.
- `--outfile` donne 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.

> [!WARNING]
> 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 :

```json title="package.json"
"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](https://tsdown.dev/) |
| Une application front | [Vite](https://vite.dev/) |
| Un projet qui dépend déjà de loaders et de plugins Webpack | Webpack |

tsdown s'appuie sur [Rolldown](https://rolldown.rs/), 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.js
```

Ce projet-là reste en CommonJS : pas de `"type": "module"` dans le `package.json`, et des imports sans extension (`./greet`).

```bash
npm install -D typescript @types/node webpack webpack-cli webpack-node-externals concurrently
```

Le `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 (erreur `TS5108`) ;
- déclarer `rootDir`, que `tsc` ne déduit plus tout seul (erreur `TS5011`) ;
- ajouter `"types": ["node"]`, sans quoi `tsc` ne trouve plus les types de `os` ou `fs`.

```json title="tsconfig.json"
{
    "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/` :

```javascript title="webpack.config.js"
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 :

```json title="package.json"
"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.
