post

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

In fast jeder Node-Schulung kommt irgendwann die Stelle, an der jemand fragt: „Wie kriege ich TypeScript sauber in mein Projekt – ohne dass ich am Ende gegen den Compiler kämpfe statt gegen den Bug?" Und meistens folgt darauf die zweite Frage: „Warum funktioniert import express from 'express' bei dir und bei mir nicht?" Genau diese beiden Fragen sind der Kern dieses Artikels. Ich zeige den Weg, den ich im Training gehe: erst ein tsconfig.json, bei dem jeder Schalter eine Bedeutung hat, dann typisierte Funktionen und Handler, und am Schluss ein Test-Setup, das TypeScript direkt versteht.

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 – Event Loop, Modulsystem, Laufzeitverhalten – noch einmal auffrischen möchte, 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 Satz, den ich im Kurs immer an den Anfang stelle: 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, das genauso läuft, als hätte man es von Hand geschrieben.

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 Zweig ist das, was in Produktion läuft. Diese Trennung im Kopf zu haben erspart viel Verwirrung – etwa die Frage, warum ein Typfehler den Build stoppt, aber im laufenden Programm nie auftaucht.

Ein Hinweis noch, weil die Frage 2024 aufkommt: Node bringt zu diesem Zeitpunkt keine eingebaute Möglichkeit mit, .ts-Dateien direkt zu starten. Der Weg über tsc oder ts-node ist nicht optional, sondern der Standard. Wer einen Blogpost von „einfach node index.ts" liest, liest über eine Zukunft, die es jetzt noch nicht gibt.

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 im Training am häufigsten vergessen wird – 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 orientiert sich an der Node-Hauptversion; für Node 20 ist das @types/node in der 20er-Reihe.

Ich stelle im Kurs die Faustregel auf: Jede JavaScript-Bibliothek ohne eigene Typen braucht ein passendes @types/*-Paket. Bekommt man beim Import die Meldung Could not find a declaration file for module 'x', ist das fast immer die Lösung.

tsconfig.json: jeder Schalter mit Absicht

Jetzt das Herzstück. Ich lasse die Teilnehmer die Datei nicht per Generator erzeugen und dann ignorieren, sondern jeden Eintrag begründen. Hier 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:

  • target legt fest, auf welche JavaScript-Version übersetzt wird. Der Default ist konservativ (historisch ES3), was auf einem modernen Node unnötig alten Code erzeugt. ES2022 passt zu Node 20 und lässt async/await, optionales Verketten und Klassenfelder unverändert durch, statt sie umständlich umzuschreiben.
  • module bestimmt das Modulformat der Ausgabe. CommonJS ist der klassische Node-Weg mit require. Wer echtes ESM will, setzt hier NodeNext – das hat aber Folgen, auf die ich gleich komme.
  • moduleResolution steuert, wie der Compiler Importpfade auflöst. Der Wert hat keinen festen Default, sondern hängt an module. Zu CommonJS passt Node10 (früher schlicht "node" geschrieben). Zu module: NodeNext gehört moduleResolution: NodeNext. Diese Paare müssen zusammenpassen, sonst gibt es Auflösungsfehler.
  • strict ist der Schalter, der TypeScript erst wertvoll macht. Er ist standardmäßig false, und ohne ihn ist TypeScript kaum mehr als JavaScript mit Doppelpunkten. strict: true aktiviert eine ganze Familie: noImplicitAny, strictNullChecks, strictFunctionTypes und weitere. Dazu unten mehr.
  • esModuleInterop regelt den Import zwischen CommonJS und ESM. Er ist ebenfalls standardmäßig false und wird – das übersehen viele – nicht durch strict mitaktiviert. Man muss ihn selbst setzen. Ohne ihn bricht import express from 'express'.
  • outDir bestimmt, wohin die kompilierten .js-Dateien geschrieben werden. Ohne Angabe landen sie neben den Quelldateien, was das Projekt vermüllt. Die Konvention ist dist. Gemeinsam mit rootDir: "src" ergibt sich eine saubere Trennung: Quellen liegen in src, das Ergebnis in dist.

skipLibCheck überspringt die Typprüfung der Deklarationsdateien in node_modules – das beschleunigt den Build spürbar und ist in der Praxis gefahrlos. 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

Ich zeige das im Kurs am liebsten mit dem 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, vor dem ich warne: Ein einziges 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, ist die sichere Wahl nicht any, sondern unknown – das 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, leitet TypeScript den Typ zwar oft ab, aber an einer Stelle wie response.json(), die von Haus aus Promise<any> liefert, würde sich sonst wieder ein any einschleichen. Die explizite Zusicherung hält den Typ präzise. 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, bevor man den Fehler weiterverarbeitet.

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 ist der Grund, warum die Prüfung req.method === "GET" nicht nur guter Stil, sondern unter strict auch nötig ist.

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

Kein Node-TypeScript-Artikel ohne diese Warnung, weil sie im Kurs verlässlich für die meisten roten Unterstreichungen sorgt. 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 aktiviert nebenbei allowSyntheticDefaultImports, sodass der natürliche Default-Import erlaubt ist.

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. Der Grund ist, dass der Import den Zustand nach dem Kompilieren beschreibt – und da existiert nur die .js. Passen module und moduleResolution nicht zusammen oder fehlt die Endung, quittiert Node das zur Laufzeit mit ERR_MODULE_NOT_FOUND. Für den Einstieg empfehle ich deshalb die CommonJS-Baseline von oben; den ESM-Weg nimmt man bewusst, wenn das Projekt ihn wirklich 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 im Training den ts-node-Weg für den schnellen Entwicklungszyklus und den kompilierten Weg für die Kette, die vor der Auslieferung läuft – dort testet man dann 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. An diesen vier Zeilen sieht man den ganzen Lebenszyklus auf einen Blick.

Der rote Faden

Wenn ich das Ganze im Kurs zusammenfasse, läuft es auf eine einfache Ordnung hinaus. TypeScript ist eine Compile-Zeit-Schicht, die verschwindet – Node führt nur JavaScript aus, und der Weg dahin führt über tsc oder in der Entwicklung über ts-node. Die tsconfig.json ist kein Zufallsprodukt, sondern eine Sammlung bewusster Entscheidungen, bei der strict und esModuleInterop die beiden Schalter mit der größten Wirkung sind. @types/node ist die Voraussetzung dafür, dass die Node-APIs überhaupt Typen haben. Und die Tests binden das zusammen, indem sie entweder über den Register-Hook direkt oder über den kompilierten Stand laufen.

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 – strict: true, esModuleInterop: true, @types/node installiert, saubere outDir/rootDir-Trennung – hat genau die Grundlage, auf der TypeScript in Node das hält, was es verspricht: Fehler früh sichtbar zu machen, statt sie in die Laufzeit durchzureichen.

Weiterführende Quellen

Kommentare

Kommentar schreiben