tsc, on-the-fly und natives Type-Stripping im Vergleich
Wer TypeScript in Node.js-Projekten einsetzt, stößt schnell auf widersprüchliche Ratschläge zu tsc, ts-node, ESM und CommonJS. Dieser Artikel zeigt die praktischen Optionen zum Ausführen von TypeScript in Node, klärt die Modul-Interop-Fallstricke zwischen ESM und CommonJS und liefert eine minimale, funktionierende tsconfig für CLI- und Build-Skript-Projekte, inklusive des nativen Type-Stripping-Supports neuerer Node-Versionen.
Inhaltsverzeichnis
- 1. Warum TypeScript in Node.js so oft Kopfschmerzen bereitet
- 2. Zwei Grundstrategien: compile-then-run vs. on-the-fly
- 3. tsc im Detail: Build-Schritt, Watch-Modus und Ausgabeverzeichnis
- 4. On-the-fly-Transpiler: ts-node, tsx und ihre Kompromisse
- 5. ESM vs. CommonJS: Das package.json-Feld type verstehen
- 6. .cts und .mts: Modultyp explizit pro Datei erzwingen
- 7. moduleResolution NodeNext richtig konfigurieren
- 8. Natives Type-Stripping in Node.js 22, 23 und neuer
- 9. Eine minimale tsconfig.json für CLI- und Build-Skript-Projekte
- 10. Zusammenfassung
- 11. FAQ
1. Warum TypeScript in Node.js so oft Kopfschmerzen bereitet
TypeScript im Browser-Bundle ist längst Routine: Ein Bundler wie Vite oder webpack übernimmt Transpilation und Modulauflösung, und Entwickler bekommen davon kaum etwas mit. In Node.js-Projekten fehlt dieser Vermittler oft komplett, besonders bei CLI-Tools, Build-Skripten oder kleinen Backend-Services ohne Framework. Hier trifft man direkt auf die Frage, wie .ts-Dateien überhaupt zu lauffähigem JavaScript werden, und die Antwort hat sich in den letzten Jahren mehrfach verschoben: tsc allein führt keinen Code aus, ts-node war lange der De-facto-Standard, ist aber langsam und ESM-seitig fehleranfällig, und seit Node 22 gibt es plötzlich einen nativen Weg ganz ohne Zusatzpaket.
Verschärft wird die Verwirrung durch das package.json-Feld type, das rückwirkend die Interpretation aller .js-Dateien im Projekt ändert, durch moduleResolution-Einstellungen mit kryptischen Namen wie NodeNext und durch die Tatsache, dass viele Tutorials und Stack-Overflow-Antworten aus einer Zeit stammen, in der Node.js noch kein natives ESM kannte. Wer heute ein neues Node-Projekt mit TypeScript aufsetzt, muss weniger raten als noch vor zwei Jahren, aber nur, wenn man die aktuellen Optionen kennt statt einer veralteten Anleitung zu folgen.
2. Zwei Grundstrategien: compile-then-run vs. on-the-fly
Im Kern gibt es genau zwei Wege, TypeScript in Node auszuführen. Die erste Strategie, compile-then-run, kompiliert das gesamte Projekt vorab mit tsc in reines JavaScript in einem Ausgabeverzeichnis wie dist/, und Node führt anschließend nur noch diese fertigen .js-Dateien aus. Das entspricht exakt dem Verhalten in Produktion, weil Node zu keinem Zeitpunkt TypeScript-Syntax sieht, und tsc meldet alle Typfehler vor dem ersten Programmstart statt erst zur Laufzeit.
Die zweite Strategie, on-the-fly, transpiliert TypeScript im Moment der Ausführung, entweder über einen Loader wie ts-node und tsx oder inzwischen nativ über Node selbst. Der Vorteil liegt im kürzeren Feedback-Zyklus während der Entwicklung, da kein separater Build-Schritt zwischen Codeänderung und Testlauf steht. Der Nachteil: Die meisten on-the-fly-Werkzeuge entfernen Typen nur syntaktisch, ohne echte Typprüfung durchzuführen, weshalb Typfehler unbemerkt bis in einen Testlauf oder sogar bis in die Produktion durchrutschen können, wenn nicht zusätzlich tsc --noEmit in der CI-Pipeline läuft.
3. tsc im Detail: Build-Schritt, Watch-Modus und Ausgabeverzeichnis
Der Compiler tsc liest die tsconfig.json, prüft Typen gegen die konfigurierten lib- und target-Einstellungen und schreibt bei aktivierter outDir-Option reines JavaScript in ein separates Verzeichnis. Für ein CLI- oder Build-Skript-Projekt reicht dafür meist ein einziger npm run build-Aufruf vor dem Deployment oder vor der Veröffentlichung auf npm. Der --watch-Flag hält den Compiler im Hintergrund aktiv und kompiliert bei jeder Dateiänderung inkrementell neu, was den Build-Schritt während der Entwicklung erträglich macht, auch wenn er dem sofortigen Ausführen von on-the-fly-Werkzeugen nicht ganz gleichkommt.
Wichtig ist die klare Trennung zwischen Quellverzeichnis und Ausgabeverzeichnis über rootDir und outDir, damit kompilierte .js-Dateien nicht versehentlich neben den .ts-Quellen landen und von Git oder vom Editor als Quellcode missverstanden werden. In der Praxis bewährt sich ein schlankes Skript-Paar in package.json: build für den einmaligen Kompilierlauf und start, das die kompilierte Datei aus dist/ mit node startet, exakt wie es später in Produktion oder im Docker-Image passiert.
# Minimaler compile-then-run Workflow fuer ein Node-CLI-Projekt
npm install --save-dev typescript @types/node
# Einmaliger Build nach tsconfig.json, Ausgabe nach dist/
npx tsc
# Watch-Modus waehrend der Entwicklung, inkrementelles Rebuild
npx tsc --watch
# Produktion: nur das kompilierte JavaScript ausfuehren
node dist/index.js
# package.json Skripte, die genau diesen Ablauf abbilden
# "scripts": {
# "build": "tsc",
# "start": "node dist/index.js",
# "dev": "tsc --watch"
# }
4. On-the-fly-Transpiler: ts-node, tsx und ihre Kompromisse
ts-node war jahrelang der Standardweg, um TypeScript-Dateien direkt mit node -r ts-node/register oder dem ts-node-Binary auszuführen. Standardmäßig führt es dabei eine vollständige Typprüfung bei jedem Start durch, was bei größeren Projekten spürbar Zeit kostet und den schnellen Dev-Loop wieder verlangsamt, den man mit on-the-fly-Transpilation eigentlich gewinnen wollte. Der Modus --transpile-only überspringt die Typprüfung und beschleunigt den Start erheblich, verschiebt Typfehler aber komplett in einen separaten tsc --noEmit-Lauf, den man dann nicht vergessen darf.
tsx löst dasselbe Problem auf esbuild-Basis und hat sich inzwischen als schnellere, unkompliziertere Alternative etabliert: Es transpiliert Dateien in Millisekunden, unterstützt ESM und CommonJS im selben Projekt ohne manuelle Loader-Konfiguration und bringt einen eingebauten Watch-Modus mit. Wie alle esbuild-basierten Werkzeuge führt tsx aber ebenfalls keine echte Typprüfung durch, sondern entfernt Typannotationen rein syntaktisch. Die pragmatische Kombination in vielen Teams: tsx oder tsx watch für den täglichen Dev-Loop, tsc --noEmit als separater CI-Schritt für die eigentliche Typsicherheit, und tsc mit outDir für den finalen Produktions-Build.
5. ESM vs. CommonJS: Das package.json-Feld type verstehen
Node.js unterstützt zwei Modulsysteme parallel: das klassische CommonJS mit require und module.exports, und das native ECMAScript-Module-System mit import und export. Welches System für eine .js-Datei gilt, entscheidet primär das Feld type in der nächstgelegenen package.json: Fehlt es oder steht es auf "commonjs", interpretiert Node jede .js-Datei als CommonJS. Steht es auf "module", gilt dieselbe Datei automatisch als ESM, mit allen Konsequenzen: kein require mehr ohne createRequire, kein __dirname und __filename ohne den Umweg über import.meta.url, dafür Top-Level-await ohne Umwege.
Für TypeScript-Projekte bedeutet das: Die tsconfig.json allein legt nicht fest, ob am Ende ESM- oder CommonJS-Code entsteht, sie muss zum type-Feld in package.json passen. Eine häufige Fehlerquelle ist ein auf "module" gesetztes type-Feld kombiniert mit einer tsconfig, die noch "module": "commonjs" ausgibt, oder umgekehrt: Node meldet dann zur Laufzeit SyntaxError: Cannot use import statement oder ERR_REQUIRE_ESM, obwohl die TypeScript-Kompilierung selbst fehlerfrei durchläuft. Ein exports-Feld in package.json macht zusätzlich explizit, welche Einstiegspunkte ein Paket für ESM- und CommonJS-Konsumenten jeweils bereitstellt.
{
"name": "cli-tool-example",
"version": "1.0.0",
"type": "module",
"main": "./dist/index.js",
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
},
"bin": {
"cli-tool-example": "./dist/index.js"
},
"engines": {
"node": ">=20.0.0"
},
"scripts": {
"build": "tsc",
"start": "node dist/index.js"
}
}
6. .cts und .mts: Modultyp explizit pro Datei erzwingen
Manchmal reicht ein einzelnes type-Feld nicht aus, etwa wenn ein Paket gleichzeitig ESM- und CommonJS-Konsumenten bedienen muss, oder wenn innerhalb eines größeren Monorepos einzelne Build-Skripte zwingend CommonJS bleiben müssen, während der Rest bereits auf ESM migriert ist. Für genau diesen Fall kennt TypeScript die Dateiendungen .mts und .cts: Eine .mts-Datei wird unabhängig vom type-Feld immer als ESM behandelt und zu .mjs kompiliert, eine .cts-Datei immer als CommonJS und zu .cjs kompiliert.
Das ist besonders nützlich für dual-publizierte npm-Pakete, die sowohl von modernen ESM-Projekten als auch von älteren CommonJS-Codebasen importiert werden sollen, ohne dass der komplette Quellcode zweimal gepflegt werden muss. In der Praxis bleiben die expliziten Endungen für die meisten CLI- oder internen Build-Skript-Projekte optional, da dort ein einheitliches Modulsystem für das gesamte Projekt völlig ausreicht. Sobald aber Interop-Grenzen im selben Repository überbrückt werden müssen, etwa ein webpack.config.cts neben ansonsten reinem ESM-Code, sind .cts und .mts die sauberste Lösung ohne Umweg über ein zweites Build-Target.
// build.mts: immer ESM, unabhaengig vom "type"-Feld in package.json
import { readFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
const configPath = fileURLToPath(new URL("./config.json", import.meta.url));
const raw = await readFile(configPath, "utf-8");
export const config = JSON.parse(raw);
// webpack.config.cts: immer CommonJS, auch in einem ESM-Projekt
import type { Configuration } from "webpack";
const config: Configuration = {
mode: "production",
entry: "./src/index.ts",
};
module.exports = config;
7. moduleResolution NodeNext richtig konfigurieren
Die Compiler-Option moduleResolution bestimmt, nach welchen Regeln TypeScript Importpfade in Module auflöst. Der veraltete Modus "node" bildet das Auflösungsverhalten von CommonJS-Node aus der Zeit vor nativem ESM nach und ignoriert dabei, dass moderne Node-Versionen bei ESM-Importen strengere Regeln anwenden. Der empfohlene Modus für neue Node-Projekte heißt "NodeNext" (gekoppelt an "module": "NodeNext"): Er liest pro Datei das type-Feld aus package.json beziehungsweise die Dateiendung .mts/.cts und wendet exakt die Regeln an, die Node zur Laufzeit ebenfalls anwendet.
Die spürbarste Konsequenz von NodeNext: Relative Importe benötigen zwingend eine explizite Dateiendung, und zwar die Endung der Ausgabedatei, nicht der Quelldatei. Ein Import aus utils.ts muss also als import { helper } from "./utils.js" geschrieben werden, obwohl die Quelldatei .ts heißt, weil Node zur Laufzeit die kompilierte .js-Datei auflöst. Wer diese Regel übersieht, bekommt typischerweise den Fehler TS2835 mit einem hilfreichen Hinweis von TypeScript selbst, der die korrekte Endung vorschlägt. Diese Umstellung fühlt sich zunächst ungewohnt an, verhindert aber genau die Import-Fehler, die sonst erst zur Laufzeit in Node auffallen, nicht schon beim Kompilieren.
8. Natives Type-Stripping in Node.js 22, 23 und neuer
Seit Node.js 22.6 gibt es einen nativen Weg, TypeScript-Dateien ohne zusätzliches npm-Paket auszuführen: das Flag --experimental-strip-types. Node entfernt dabei ausschließlich Typannotationen aus dem Quelltext, ohne sie zu prüfen, konzeptionell vergleichbar mit dem, was tsx oder esbuild bereits leisten, nur direkt in der Node-Laufzeit selbst. Unterstützt werden dabei nur „erasable" Syntax-Konstrukte: reine Typannotationen, Interfaces und Type-Aliase funktionieren, während enum-Deklarationen, Parameter-Properties in Konstruktoren und Namespaces zusätzliche Laufzeit-Konstrukte erzeugen und je nach Node-Version entweder gesondert behandelt oder abgelehnt werden.
Mit Node 23.6 ging die Unterstützung einen wichtigen Schritt weiter: Type-Stripping ist dort standardmäßig aktiv, .ts-Dateien lassen sich ohne jedes Flag direkt mit node datei.ts starten. Wichtig bleibt die Einschränkung, dass dabei zu keinem Zeitpunkt eine echte Typprüfung stattfindet, es handelt sich weiterhin um reines Entfernen von Syntax, nicht um einen Ersatz für tsc --noEmit in der CI-Pipeline. Für kleine CLI-Skripte oder einmalige Build-Helfer, die bislang ein ts-node- oder tsx-Abhängigkeit nur für diesen einen Zweck mitgeschleppt haben, ist natives Type-Stripping in aktuellen Node-Versionen aber bereits eine ernsthafte, abhängigkeitsfreie Alternative.
# Node 22.6 bis 23.5: Flag zum Aktivieren des Type-Strippings erforderlich
node --experimental-strip-types src/index.ts
# Node 23.6+: Type-Stripping ist standardmaessig aktiv, kein Flag noetig
node src/index.ts
# Erasable Syntax funktioniert direkt (Interfaces, Type-Aliase, Annotationen)
# enum und Namespaces benoetigen ggf. zusaetzliche Node-Flags oder
# muessen ueber "erasableSyntaxOnly": true in der tsconfig ausgeschlossen werden
# Typpruefung findet beim Type-Stripping NICHT statt, daher zusaetzlich in CI:
npx tsc --noEmit
9. Eine minimale tsconfig.json für CLI- und Build-Skript-Projekte
Für ein schlankes Node-CLI- oder Build-Skript-Projekt braucht es keine der zahlreichen Compiler-Optionen, die man aus großen Frontend-Setups kennt. Entscheidend sind wenige, klar aufeinander abgestimmte Werte: module und moduleResolution auf "NodeNext" für korrekte Modulauflösung, target passend zur minimal unterstützten Node-Version, outDir und rootDir für eine saubere Trennung von Quell- und Build-Verzeichnis, sowie strict: true, weil nachträgliches Einschalten der Strict-Modi in einem wachsenden Projekt deutlich schmerzhafter ist als ein strenger Start.
Zusätzlich empfiehlt sich esModuleInterop für reibungsloseren Umgang mit CommonJS-Paketen, die noch keine ESM-Exports anbieten, sowie skipLibCheck, um die Typprüfung nicht unnötig auf die .d.ts-Dateien von Drittanbieter-Paketen auszuweiten. Die folgende Tabelle vergleicht die in diesem Artikel besprochenen Ausführungswege noch einmal direkt gegenüber, damit die Entscheidung für ein konkretes Projekt leichter fällt.
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"rootDir": "./src",
"outDir": "./dist",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": false,
"sourceMap": true
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"]
}
| Ansatz | Für Produktion geeignet | Typischer Stolperstein | Empfohlener Einsatz |
|---|---|---|---|
| tsc Build + node dist/ | Ja, Standardweg | Rebuild bei jeder Änderung nötig | CI/CD, Docker-Images, npm-Pakete |
| ts-node (mit Typecheck) | Eher nicht, zu langsam | Voller Typecheck bei jedem Start | Lokales Debugging mit Typprüfung |
| tsx / esbuild-basiert | Nein, kein Typecheck | Typfehler bleiben unbemerkt | Schneller Dev-Loop, Watch-Modus |
| Natives Type-Stripping | Bedingt, ab Node 23 stabiler | Keine Typprüfung, enum eingeschränkt | CLI-Skripte ohne Build-Schritt |
| CommonJS ohne type-Feld | Riskant bei neuen Paketen | Stille require/import-Interop-Fehler | Nur Bestandscode, Migration einplanen |
Die Tabelle zeigt: Es gibt keinen universell „besten" Ansatz, sondern eine klare Aufgabenteilung. Für den täglichen Dev-Loop lohnt sich Geschwindigkeit, für den finalen Build lohnt sich Sicherheit, und beides lässt sich in einem einzigen Projekt kombinieren, ohne Kompromisse einzugehen.
Mironsoft
TypeScript-Setup, Build-Tooling und Node.js-Entwicklung für Magento- und Hyvä-Projekte
TypeScript-Setup ohne Kopfschmerzen aufsetzen?
Wir richten euer Node.js-Tooling, Build-Skripte und headless Integrationen mit einer sauberen tsconfig, korrektem ESM/CommonJS-Setup und modernem Type-Stripping ein, damit ihr euch auf euren Code konzentrieren könnt statt auf Modulfehler.
tsconfig-Audit
Bestehende Node-Projekte auf NodeNext und Strict-Mode migrieren
Build-Tooling
CLI-Tools, Build-Skripte und Deployment-Pipelines mit TypeScript
Headless-Integrationen
Type-sichere Anbindung von Magento-APIs an Node.js-Services
10. Zusammenfassung
TypeScript mit Node.js muss kein Ratespiel sein, sobald man die beiden Grundstrategien klar auseinanderhält: tsc plus node dist/index.js für Produktion, weil es Typfehler vor dem Start meldet und exakt das Verhalten der Produktionsumgebung abbildet, und ein schneller on-the-fly-Transpiler wie tsx oder natives Type-Stripping für den täglichen Dev-Loop, kombiniert mit einem separaten tsc --noEmit-Schritt für die eigentliche Typsicherheit. Das type-Feld in package.json, moduleResolution: "NodeNext" und bei Bedarf explizite .mts/.cts-Endungen lösen die häufigsten ESM/CommonJS-Interop-Probleme, sofern man sie von Anfang an konsequent anwendet statt sie nachträglich zu reparieren.
Natives Type-Stripping ab Node 22.6, standardmäßig aktiv ab Node 23.6, macht viele kleine CLI-Skripte und Build-Helfer inzwischen komplett abhängigkeitsfrei ausführbar, ersetzt aber keine echte Typprüfung in der CI-Pipeline. Wer diese Bausteine kennt und in einer schlanken, gut kommentierten tsconfig.json zusammenführt, spart sich die immer wiederkehrenden Modulfehler, die sonst gefühlt jedes zweite Node-TypeScript-Projekt neu erfindet.
TypeScript mit Node.js, das Wichtigste auf einen Blick
Compile-then-run für Produktion
tsc baut nach dist/, node dist/index.js startet exakt dieses Ergebnis, Typfehler stoppen den Build.
On-the-fly für den Dev-Loop
tsx oder natives Type-Stripping beschleunigen die Entwicklung, ersetzen aber nicht tsc --noEmit.
ESM vs. CommonJS
Das type-Feld in package.json steuert die Interpretation aller .js-Dateien im Projekt.
Natives Type-Stripping
Ab Node 22.6 per Flag, ab Node 23.6 standardmäßig, ohne echte Typprüfung zur Laufzeit.