TypeScript in Node.js: Setup, Typen und Tests
TypeScript in einem Node-Projekt aufsetzen: tsconfig-Essentials verstehen, async und Handler typisieren und die Tests laufen lassen, Schritt für Schritt aus der Projektpraxis.
TypeScript ist keine Node-Laufzeit und keine Sprache, die Node ausführt. Es ist eine Prüfschicht davor: Sie greift beim Übersetzen und ist danach spurlos weg. Aus dieser einen Eigenschaft folgt alles Weitere – bis hin zu der Frage, die beim Aufsetzen eines Projekts eigentlich zur Debatte steht. Sie lautet nämlich nicht, welchen Schalter man in die tsconfig.json schreibt, sondern wo ein Fehler auftauchen soll: am Schreibtisch, während der Cursor noch in der Zeile steht, oder beim Nutzer, während die Anfrage schon läuft. TypeScript zieht diesen Fehlerort nach vorne. Der Preis ist ein Build-Schritt und eine Handvoll bewusster Konfigurationsentscheidungen; der Ertrag ist ein Feedback-Zyklus, in dem zwischen „ich baue Mist“ und „ich sehe den Mist“ Sekunden liegen statt Minuten.
Redaktionelle Einordnung (Juli 2026): Der Haupttext beschreibt Node 20 und TypeScript 5.4 im März 2024. Aktuelle Node-Versionen können TypeScript mit löschbarer Syntax direkt ausführen. Node entfernt dabei Typen, führt aber keine Typprüfung durch und ignoriert tsconfig.json; für TypeScript-spezifische Laufzeitkonstrukte oder einen vollständigen Build bleibt ein externes Werkzeug nötig. Bei modernen Node-Projekten sind außerdem node16 oder nodenext die passenden Auflösungsmodi. node10 gehört zum historischen CommonJS-Setup dieses Artikels.
Der Rest ist Handwerk, und genau das folgt hier: eine Konfiguration, in der jeder Eintrag eine Begründung hat, typisierte Funktionen und Handler und zuletzt ein Test-Setup, das .ts-Dateien ohne Umweg ausführt.
Ich setze voraus, dass Node 20 (das aktive LTS) installiert ist und ein npm init schon gelaufen ist. Alles andere bauen wir zusammen auf. Wer die Ebene darunter noch einmal auffrischen möchte, also Event Loop, Modulsystem und Laufzeitverhalten, findet das in meinem Node.js-Training; hier bleiben wir eine Etage höher, bei Typen und Werkzeugen.
Was TypeScript in Node überhaupt macht
Der entscheidende Punkt gleich am Anfang: Node führt kein TypeScript aus. Node kennt nur JavaScript. TypeScript ist eine Schicht, die zur Compile-Zeit prüft und dann verschwindet. Die Typen werden beim Übersetzen gelöscht, man nennt das Type Erasure. Zur Laufzeit existiert kein einziger Typ mehr, keine interface, kein : number. Übrig bleibt gewöhnliches JavaScript.
Daraus folgt ein Ablauf, der 2024 der Normalfall ist: Eine .ts-Datei geht durch den Compiler tsc und wird zu einer .js-Datei. Diese .js-Datei führt node aus. Für die tägliche Entwicklung gibt es eine Abkürzung, ts-node übersetzt und startet in einem Rutsch, ohne dass eine sichtbare .js-Datei entsteht. Aber ausgeliefert wird am Ende immer kompiliertes JavaScript.
flowchart LR A[source.ts] -->|tsc| B[dist/source.js] B -->|node| C[Laufzeit] A -.->|ts-node<br/>Entwicklung| C
Der gestrichelte Zweig ist der Dev-Komfort, der durchgezogene das, was in Produktion läuft. Diese Trennung im Kopf erspart viel Verwirrung, etwa warum ein Typfehler den Build stoppt, im laufenden Programm aber nie auftaucht.
Ein Hinweis noch: Node bringt 2024 keine eingebaute Möglichkeit mit, .ts-Dateien direkt zu starten. An tsc oder ts-node führt kein Weg vorbei, das ist der Standard. Wer einen Blogpost von „einfach node index.ts“ liest, liest über eine Zukunft, die es jetzt noch nicht gibt.
Typen als ausführbare Verträge
Der Perspektivwechsel, der mir in Projekten am meisten gebracht hat: Ein Typ ist keine Dokumentation, sondern ein Vertrag, den eine Maschine bei jedem Build durchsetzt. function total(items: LineItem[]): number hält zwei Zusagen fest, eine an jeden Aufrufer und eine an mich als Autor, und der Compiler verweigert den Build, sobald eine Seite ihren Teil verletzt. Der Hebel liegt dabei im Zeitpunkt: Entscheidend ist, wann Korrektheit eingefordert wird. Ohne solche Verträge rutscht die Prüfung nach hinten, bis ein falscher Feldname oder ein vergessenes undefined erst beim Nutzer über einen Fehlerreport auffällt. Verträge, die vorher greifen, melden den Bruch, während der Cursor noch in der Zeile steht, und drücken die Länge des Feedback-Zyklus auf Sekunden.
Kostenlos ist das nicht: ein Compile-Schritt, die Disziplin, Typen zu pflegen statt sie mit any wegzudrücken, und Momente, in denen der Compiler auf etwas besteht, das zur Laufzeit nie gebrochen wäre. Die Entscheidung fällt deshalb an einer klaren Kante. Je länger ein Stück Code lebt, je mehr Hände es anfassen und je teurer ein Produktionsfehler ist, desto klarer gewinnt der Vertrag. Ein Wegwerfskript braucht ihn nicht; ein Zahlungspfad oder ein öffentliches API holt jede dieser Sekunden zurück.
Das Fundament: die richtigen Pakete
Bevor wir eine Zeile Konfiguration schreiben, brauchen wir drei Dinge als devDependency:
npm install --save-dev typescript @types/node ts-node
typescript liefert den Compiler. ts-node ist das Dev-Werkzeug für den direkten Start. Und @types/node ist das Paket, das ich in Projekt-Setups am häufigsten vergessen sehe, mit spürbaren Folgen. Es enthält die Typdeklarationen für die Node-Kern-APIs: fs, http, events, process, Buffer und den ganzen Rest. Ohne dieses Paket weiß der Compiler schlicht nicht, dass es process gibt, und meldet Cannot find name 'process'. Die Version folgt der Node-Hauptversion, für Node 20 also die 20er-Reihe.
Faustregel: Jede JavaScript-Bibliothek ohne eigene Typen braucht ein passendes @types/*-Paket, angekündigt durch die Meldung Could not find a declaration file for module 'x'.
tsconfig.json: jeder Schalter mit Absicht
Jetzt das Herzstück. Ich begründe jeden Eintrag, statt die Datei per Generator zu erzeugen und dann zu ignorieren. Eine Baseline für ein klassisches CommonJS-Node-Projekt:
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"moduleResolution": "Node10",
"strict": true,
"esModuleInterop": true,
"outDir": "dist",
"rootDir": "src",
"skipLibCheck": true,
"resolveJsonModule": true,
"sourceMap": true
},
"include": ["src/**/*"]
}
Die wichtigsten Schalter und was sie tatsächlich bewirken:
targetlegt fest, auf welche JavaScript-Version übersetzt wird. Der Default ist konservativ (historischES3), was auf einem modernen Node unnötig alten Code erzeugt.ES2022passt zu Node 20 und lässtasync/await, optionales Verketten und Klassenfelder unverändert durch, statt sie umständlich umzuschreiben.modulebestimmt das Modulformat der Ausgabe.CommonJSist der klassische Node-Weg mitrequire. Wer echtes ESM will, setzt hierNodeNext, das hat aber Folgen.moduleResolutionsteuert, wie der Compiler Importpfade auflöst. Der Wert hat keinen festen Default, sondern hängt anmodule. ZuCommonJSpasstNode10(früher schlicht"node"geschrieben). Zumodule: NodeNextgehörtmoduleResolution: NodeNext. Diese Paare müssen zusammenpassen, sonst gibt es Auflösungsfehler.strictist der Schalter, der TypeScript erst wertvoll macht. Er ist standardmäßigfalse, und ohne ihn ist TypeScript kaum mehr als JavaScript mit Doppelpunkten.strict: trueaktiviert eine ganze Familie:noImplicitAny,strictNullChecks,strictFunctionTypesund weitere. Dazu unten mehr.esModuleInteropregelt den Import zwischen CommonJS und ESM. Er ist ebenfalls standardmäßigfalseund wird, das übersehen viele, nicht durchstrictmitaktiviert. Man muss ihn selbst setzen. Ohne ihn brichtimport express from 'express'.outDirbestimmt, wohin die kompilierten.js-Dateien geschrieben werden. Ohne Angabe landen sie neben den Quelldateien, was das Projekt vermüllt. Die Konvention istdist. Gemeinsam mitrootDir: "src"ergibt sich eine saubere Trennung: Quellen liegen insrc, das Ergebnis indist.
skipLibCheck überspringt die Typprüfung der Deklarationsdateien in node_modules und kann den Build beschleunigen. Gefahrlos ist das nicht in jedem Projekt: Widersprüchliche oder fehlerhafte Deklarationen können dadurch unbemerkt bleiben. resolveJsonModule erlaubt import config from './config.json'. sourceMap erzeugt Source-Maps, damit der Debugger auf die .ts-Zeilen zeigt und nicht auf das kompilierte JavaScript.
Warum strict den Unterschied macht
Am deutlichsten wird das am Fehlerfall. Ohne strict, genauer: ohne noImplicitAny, geht Folgendes durch:
function total(items) {
return items.reduce((sum, item) => sum + item.price, 0);
}
Der Parameter items hat hier den impliziten Typ any, und item.price ebenso. Der Compiler schweigt, und man hat exakt null Typsicherheit, ein Tippfehler wie item.prise fällt erst zur Laufzeit auf. Das ist der any-Wildwuchs: Ein untypisiertes any breitet sich aus, denn alles, was man daraus liest, ist wieder any.
Mit strict: true verlangt der Compiler eine Angabe:
interface LineItem {
price: number;
quantity: number;
}
function total(items: LineItem[]): number {
return items.reduce((sum, item) => sum + item.price * item.quantity, 0);
}
Jetzt ist item.prise ein Fehler zur Compile-Zeit, und die Rückgabe ist garantiert eine number. Wenn wirklich einmal ein Wert von außen kommt, dessen Form man nicht kennt, greift man zu unknown statt zu any, denn unknown zwingt dazu, den Typ vor der Benutzung zu prüfen, statt ihn stillschweigend zu unterlaufen.
Async richtig typisieren
Asynchroner Code ist in Node allgegenwärtig, und TypeScript beschreibt das präzise. Eine async-Funktion gibt immer einen Promise<T> zurück, auch wenn im Rumpf ein nackter Wert steht, wird er in ein Promise verpackt. Umgekehrt entpackt await einen Promise<T> wieder zu T.
interface User {
id: number;
name: string;
email: string;
}
async function fetchUser(id: number): Promise<User> {
const response = await fetch(`https://api.example.com/users/${id}`);
if (!response.ok) {
throw new Error(`Request failed with status ${response.status}`);
}
return response.json() as Promise<User>;
}
Der Rückgabetyp Promise<User> ist die Zusage an alle Aufrufer: Wer await fetchUser(1) schreibt, bekommt einen User, nichts anderes. Fällt die Annotation weg, würde sich an einer Stelle wie response.json(), die von Haus aus Promise<any> liefert, wieder ein any einschleichen. Ein await kann zudem eine Ausnahme auslösen; unter strict fängt der catch-Zweig sie als unknown, was erneut zur bewussten Typprüfung zwingt.
Einen HTTP-Handler und einen EventEmitter typisieren
Zwei Muster begegnen einem in Node ständig: der HTTP-Handler und der Event-basierte Ablauf. Beide lassen sich sauber typisieren, sobald @types/node installiert ist.
import { createServer, IncomingMessage, ServerResponse } from "node:http";
const server = createServer((req: IncomingMessage, res: ServerResponse): void => {
if (req.method === "GET" && req.url === "/health") {
res.statusCode = 200;
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({ status: "ok" }));
return;
}
res.statusCode = 404;
res.end("Not Found");
});
server.listen(3000, () => {
console.log("Server listening on port 3000");
});
IncomingMessage und ServerResponse sind die Node-Typen für Anfrage und Antwort. Der Editor weiß dadurch, dass req.method ein string | undefined ist, und genau dieses undefined macht die Prüfung req.method === "GET" unter strict nötig, nicht nur zu gutem Stil.
Der EventEmitter ist der zweite Klassiker. Er ist von Natur aus dynamisch, aber man kann die Ereignisnamen und ihre Nutzlast über ein eigenes Interface festhalten:
import { EventEmitter } from "node:events";
interface OrderEventMap {
created: [orderId: string];
cancelled: [orderId: string, reason: string];
}
class OrderBus extends EventEmitter {
on<K extends keyof OrderEventMap>(
event: K,
listener: (...args: OrderEventMap[K]) => void
): this {
return super.on(event, listener as (...args: unknown[]) => void);
}
emit<K extends keyof OrderEventMap>(event: K, ...args: OrderEventMap[K]): boolean {
return super.emit(event, ...args);
}
}
const bus = new OrderBus();
bus.on("created", (orderId) => {
console.log(`Order ${orderId} was created`);
});
bus.emit("created", "order-42");
Der Gewinn: bus.emit("created") ohne die orderId ist jetzt ein Compile-Fehler, und im Listener ist orderId automatisch ein string. Man hat den losen Ereignismechanismus in einen typisierten Vertrag verwandelt, ohne die Node-Mechanik zu verändern. Wer solche Muster auch im Frontend anwenden will, findet die React-Seite davon in TypeScript in React.
Der Interop-Stolperstein: CommonJS und ESM
Dieser Stolperstein sorgt in der Praxis für die meisten roten Unterstreichungen, deshalb gehört die Warnung hierher. Viele npm-Pakete, etwa Express, sind CommonJS-Module mit einem einzigen module.exports. Der intuitive Import
import express from "express";
funktioniert nur, wenn esModuleInterop: true gesetzt ist. Ohne diesen Schalter besteht TypeScript auf der sperrigen Form import * as express from "express", die dann oft zur Laufzeit an anderer Stelle bricht. Der Schalter fügt intern die passenden Interop-Helfer ein und erlaubt so den natürlichen Default-Import.
Der zweite Teil des Stolpersteins betrifft echtes ESM. Wer in der package.json "type": "module" setzt und module: "NodeNext" konfiguriert, muss relative Importe mit Dateiendung schreiben:
import { total } from "./billing.js";
Das irritiert jedes Mal: Man schreibt .js, obwohl die Quelldatei billing.ts heißt, weil der Import den Zustand nach dem Kompilieren beschreibt. Fehlt die Endung oder passen module und moduleResolution nicht zusammen, quittiert Node das mit ERR_MODULE_NOT_FOUND. Für den Einstieg empfehle ich deshalb die CommonJS-Baseline; den ESM-Weg nimmt man bewusst, wenn das Projekt ihn braucht.
Tests, die TypeScript direkt verstehen
Bleibt die Frage, wie man .ts-Tests ausführt, ohne bei jeder Änderung von Hand zu kompilieren. Mit Mocha gibt es zwei gangbare Wege.
Der bequeme Weg für die Entwicklung nutzt ts-node als Register-Hook. Eine .mocharc.json im Projektwurzelverzeichnis genügt:
{
"require": "ts-node/register",
"extensions": ["ts"],
"spec": ["test/**/*.spec.ts"]
}
Damit versteht Mocha .ts-Dateien direkt, ohne separaten Build-Schritt. Ein Test dazu, hier mit dem eingebauten node:assert:
import assert from "node:assert";
import { total } from "../src/billing";
describe("total", () => {
it("sums price times quantity", () => {
const result = total([{ price: 10, quantity: 2 }]);
assert.strictEqual(result, 20);
});
});
Der zweite Weg ist der kompilierte: erst tsc, dann die erzeugten .js-Tests laufen lassen.
tsc && mocha "dist/**/*.spec.js"
Ich empfehle den ts-node-Weg für den schnellen Entwicklungszyklus und den kompilierten Weg für die Kette vor der Auslieferung, dort testet man exakt das JavaScript, das auch produktiv geht. Die passenden Skripte in der package.json machen das greifbar:
{
"scripts": {
"dev": "ts-node src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"test": "mocha"
}
}
dev startet direkt aus dem Quellcode, build erzeugt das dist-Verzeichnis, start fährt das kompilierte Ergebnis, test liest die .mocharc.json. Vier Zeilen, der ganze Lebenszyklus.
Der rote Faden
Die Ordnung dahinter ist einfach: TypeScript prüft zur Compile-Zeit und verschwindet, in der tsconfig.json tragen strict und esModuleInterop am meisten, @types/node gibt den Node-APIs überhaupt Typen, und die Tests laufen über den Register-Hook oder den kompilierten Stand.
Der Fehler, den ich am häufigsten sehe, ist nicht der komplizierte generische Typ, sondern das stille any, das sich ohne strict ausbreitet, bis von der Typsicherheit nichts mehr übrig ist. Wer die Baseline aus diesem Artikel übernimmt, also strict: true, esModuleInterop: true, @types/node und eine saubere Trennung von outDir und rootDir, verschiebt damit den Ort, an dem Fehler auftauchen: an den Schreibtisch statt zum Nutzer. Eine Formalie ist das nicht, es ist die eigentliche Wirkung – kürzere Feedback-Zyklen, weniger Reibung im Review und höhere Qualität in dem Code, der wirklich ausgeliefert wird. Die Grenze gehört dazu, denn der Vertrag lohnt sich dort, wo Code lange bleibt und durch viele Hände geht, im Wegwerfskript dagegen nicht. Wer sie kennt, holt aus TypeScript in Node genau das heraus, was es verspricht, Fehler früh sichtbar zu machen, statt sie in die Laufzeit durchzureichen.
Weiterführende Quellen
- TSConfig-Referenz: https://www.typescriptlang.org/tsconfig/
- TypeScript Handbook, Compiler-Optionen wählen: https://www.typescriptlang.org/docs/handbook/modules/guides/choosing-compiler-options.html
- Announcing TypeScript 5.4: https://devblogs.microsoft.com/typescript/announcing-typescript-5-4/
- ts-node (npm): https://www.npmjs.com/package/ts-node
- ts-node Mocha-Rezept: https://typestrong.org/ts-node/docs/recipes/mocha/
- @types/node (npm): https://www.npmjs.com/package/@types/node
- Mocha: https://mochajs.org/
- Node.js: Modules – TypeScript
- TypeScript:
moduleResolution
Wie fandest du diesen Beitrag?
Kommentare