# TypeScript in Node.js: Setup, Typen und Tests

URL: https://www.mikebild.dev/de/blog/typescript-in-node/

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](https://www.mikebild.dev/de/blog/nodejs-training-event-loop-architektur/); 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.

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

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

```jsonc
{
  "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.
- `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` 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:

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

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

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

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

```ts
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](https://www.mikebild.dev/de/blog/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

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

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

```json
{
  "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`:

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

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

```json
{
  "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](https://nodejs.org/dist/latest/docs/api/typescript.html)
- [TypeScript: `moduleResolution`](https://www.typescriptlang.org/tsconfig/moduleResolution.html)
