TypeScript mit Node.js: Setup ohne Kopfschmerzen
<T>
type
TypeScript · Node.js · tsconfig · Build Tools
TypeScript mit Node.js: Setup ohne Kopfschmerzen
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.

13 Min. Lesezeit tsc · ts-node · tsx ESM · CommonJS · NodeNext

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.

11. FAQ: TypeScript mit Node.js

1Kann Node.js TypeScript-Dateien direkt ausführen?
Seit Node 22.6 ja, mit dem Flag --experimental-strip-types. Ab Node 23.6 ist dieses Type-Stripping standardmäßig aktiv, ganz ohne Flag.
2Was ist der Unterschied zwischen ts-node und tsx?
ts-node prüft standardmäßig vollständig Typen, tsx basiert auf esbuild, startet schneller und handhabt ESM/CommonJS-Interop unkomplizierter.
3Was macht das type-Feld in package.json genau?
Es legt fest, ob .js-Dateien als CommonJS oder als ECMAScript-Module interpretiert werden, inklusive require/import, __dirname und Top-Level-await.
4Wofür stehen die Dateiendungen .mts und .cts?
.mts ist immer ESM (kompiliert zu .mjs), .cts ist immer CommonJS (kompiliert zu .cjs), unabhängig vom type-Feld in package.json.
5Warum verlangt moduleResolution NodeNext Dateiendungen in Importen?
NodeNext bildet das Laufzeit-Auflösungsverhalten von Node nach, das die vollständige Endung der Ausgabedatei benötigt, etwa './utils.js' statt './utils'.
6Prüft natives Type-Stripping in Node auch Typen?
Nein, es entfernt Typannotationen nur syntaktisch. Echte Typsicherheit erfordert weiterhin einen separaten tsc --noEmit-Lauf.
7Werden enum und Namespaces beim nativen Type-Stripping unterstützt?
Nur eingeschränkt, da sie zusätzliche Laufzeit-Konstrukte erzeugen. Reine Typannotationen, Interfaces und Type-Aliase funktionieren dagegen problemlos.
8Welche tsconfig-Einstellungen sind für ein Node-CLI-Projekt Pflicht?
module/moduleResolution auf NodeNext, passendes target, getrennte rootDir/outDir sowie strict: true, ergänzt um esModuleInterop und skipLibCheck.
9Sollte ich tsc oder tsx für den Produktions-Build verwenden?
Für Produktion immer tsc mit outDir wegen echter Typprüfung. tsx und Type-Stripping eignen sich für den Dev-Loop, nicht als Ersatz für den geprüften Build.
10Warum bekomme ich ERR_REQUIRE_ESM oder Cannot use import statement?
Meist ein Konflikt zwischen dem type-Feld in package.json und der module-Einstellung in tsconfig.json. Beide müssen konsistent auf ESM oder CommonJS zeigen.