TypeScript mit Express typsicher gestalten
AI generated
<T>
type
TypeScript · Express · Node.js · Backend
TypeScript mit Express typsicher gestalten
Request, Response und Middleware ohne any

Express liefert von Haus aus nur schwach typisierte Request- und Response-Objekte. Mit generischen Typen fuer Body, Params und Query, typisierten Middleware-Ketten und einer klaren Fehlerklassen-Hierarchie wird aus einem fragilen JavaScript-Framework eine TypeScript-Backend-Architektur, die Fehler schon beim Kompilieren statt erst zur Laufzeit meldet.

18 Min. Lesezeit RequestHandler · Middleware · Zod · Error-Handling Express 4/5 · TypeScript 5.x · Node.js

1. Warum Express von Haus aus schwach typisiert ist

Express selbst ist eine reine JavaScript-Bibliothek. Die Typen kommen aus dem separaten Paket @types/express, das die Signatur von Handlern, Middleware und Router-Methoden beschreibt, ohne jedoch die tatsaechlichen Inhalte von Body, Query oder Params einzuschraenken. In der Praxis bedeutet das: req.body ist standardmaessig vom Typ any, und wer TypeScript mit Express nutzt, bekommt zunaechst nur oberflaechlichen Schutz, keine echte Typsicherheit fuer die Nutzdaten einer Anfrage.

Das ist kein Zufall, sondern eine Designentscheidung aus einer Zeit, in der Express deutlich aelter war als TypeScript selbst. Wer Express typsicher betreiben will, muss die generischen Parameter der Handler-Typen aktiv nutzen und darf sich nicht auf die Standardwerte verlassen. Genau darum geht es in diesem Artikel: von generischen Request-Typen ueber Middleware-Ketten bis zu einer sauberen Fehlerklassen-Hierarchie, die typische Laufzeitfehler schon beim Kompilieren sichtbar macht.

Der Aufwand lohnt sich besonders in mittelgrossen Teams. Ein typsicheres Express-Setup verhindert, dass ein Controller auf ein Feld zugreift, das der Router gar nicht garantiert, oder dass eine Middleware ein Feld im Request-Objekt erwartet, das nie gesetzt wurde. Diese Klasse von Fehlern verschwindet nicht durch Tests allein, sie verschwindet durch Typen, die der Compiler bei jedem Build prueft.

2. Request und Response generisch typisieren

Der Express-Typ Request ist generisch ueber vier Parameter: Route-Params, Response-Body, Request-Body und Query-Parameter. Wer TypeScript mit Express ernsthaft betreibt, definiert fuer jede Route eigene Typen fuer diese vier Positionen, statt sich auf implizites any zu verlassen. Der RequestHandler-Typ nimmt dieselben vier generischen Parameter entgegen und macht Handler-Funktionen von Anfang bis Ende durchgaengig typisiert.

Besonders wichtig ist die Reihenfolge der generischen Parameter: Params, ResBody, ReqBody, ReqQuery. Wer diese Reihenfolge nicht kennt, tippt haeufig den Response-Body an die Stelle des Request-Bodys, was der Compiler zwar meist erkennt, aber erst nach einer verwirrenden Fehlermeldung. Ein wiederkehrendes Express-typsicher-Pattern ist deshalb, die vier Typen in einem eigenen Type Alias zusammenzufassen und diesen Alias fuer jede Route zu exportieren, statt die generischen Parameter jedes Mal neu auszuschreiben.


// user-routes.types.ts — explicit generic parameters for Express handlers
import type { Request, Response, NextFunction, RequestHandler } from "express";

interface UserParams {
  userId: string;
}

interface CreateUserBody {
  email: string;
  displayName: string;
}

interface UserQuery {
  includeArchived?: string;
}

interface UserResponseBody {
  id: string;
  email: string;
  displayName: string;
  createdAt: string;
}

// Order matters: Params, ResBody, ReqBody, ReqQuery
export type CreateUserHandler = RequestHandler<
  UserParams,
  UserResponseBody,
  CreateUserBody,
  UserQuery
>;

export const createUser: CreateUserHandler = (req, res, next) => {
  // req.body is now CreateUserBody, not any
  const { email, displayName } = req.body;
  // req.params.userId is a typed string, not implicit any
  res.status(201).json({
    id: req.params.userId,
    email,
    displayName,
    createdAt: new Date().toISOString(),
  });
};

Ein haeufiger Fehler beim Einstieg: Entwickler typisieren nur den Body, lassen Params und Query aber implizit auf ParamsDictionary und ParsedQs stehen. Das funktioniert, verschenkt aber genau den Teil der Typsicherheit, der URL-Parameter betrifft. Ein Query-Parameter wie includeArchived bleibt dann ein beliebiger String-oder-Array-Typ statt eines klar definierten, optionalen Strings, was in Bedingungen spaeter zu unnoetigen Typ-Assertions fuehrt.

3. Middleware-Ketten typsicher verketten

Middleware in Express reicht Informationen oft ueber zusaetzliche Felder am Request-Objekt weiter, etwa einen authentifizierten Benutzer, der von einer Auth-Middleware gesetzt und von spaeteren Handlern gelesen wird. Ohne TypeScript ist das ein reines Vertrauensverhaeltnis: Der Handler geht davon aus, dass req.user existiert, ohne dass der Compiler das jemals pruefen koennte. Mit Modul-Augmentation laesst sich das Request-Interface global erweitern, was aber den Nachteil hat, dass das Feld ueberall als vorhanden gilt, auch dort, wo die Middleware nie durchlaufen wurde.

Der robustere Ansatz fuer Express typsicher ist ein eigener, engerer Typ pro Middleware-Kette: Eine Middleware, die einen Benutzer anreichert, gibt einen erweiterten Request-Typ an nachfolgende Handler weiter, ohne das globale Interface zu veraendern. Das erfordert etwas mehr Typarbeit, verhindert aber, dass ein Handler faelschlich annimmt, ein Feld sei immer vorhanden, obwohl die zugehoerige Middleware in einer anderen Route gar nicht registriert wurde.


// auth-middleware.ts — narrower request type instead of global augmentation
import type { Request, Response, NextFunction } from "express";

interface AuthUser {
  id: string;
  role: "admin" | "editor" | "viewer";
}

export interface AuthenticatedRequest extends Request {
  user: AuthUser;
}

export function requireAuth(
  req: Request,
  res: Response,
  next: NextFunction
): void {
  const token = req.headers.authorization;
  if (!token) {
    res.status(401).json({ error: "Missing authorization header" });
    return;
  }

  const user: AuthUser = { id: "u_123", role: "editor" };
  // Type assertion is safe here because we just constructed the object
  (req as AuthenticatedRequest).user = user;
  next();
}

// Handler that requires an authenticated request, enforced by the type
export function listOwnItems(
  req: AuthenticatedRequest,
  res: Response
): void {
  res.json({ owner: req.user.id, role: req.user.role, items: [] });
}

4. Typsichere Routen mit Router und Param-Typen

Der Router von Express unterstuetzt dieselben generischen Parameter wie RequestHandler, wird in der Praxis aber selten mit diesen Typen genutzt. Wer Routen in getrennten Dateien organisiert, sollte fuer jede Ressource einen eigenen Router mit klar typisierten Handlern exportieren, statt Handler-Funktionen lose zu deklarieren und erst beim Registrieren zusammenzufuehren. Das TypeScript-Pattern dafuer: Route-Parameter als eigenes Interface definieren, dessen Feldnamen exakt den Platzhaltern im Pfad entsprechen, etwa :userId zu { userId: string }.

Ein subtiler Punkt bei Express: Route-Parameter sind zur Laufzeit immer Strings, auch wenn der Pfad wie eine Zahl aussieht, etwa /users/42. Ein haeufiger Fehler ist, den Parameter direkt als number zu typisieren, was der Compiler nicht verhindert, obwohl der tatsaechliche Wert ein String bleibt. Die korrekte Loesung ist, den Parameter als String zu typisieren und explizit mit Number() oder einer Validierungsbibliothek zu konvertieren, bevor er als Zahl verwendet wird.

5. Fehlerbehandlung: Error-Middleware und Fehlerklassen

Express erkennt Error-Middleware an der Signatur mit vier Parametern: (err, req, res, next). TypeScript kann diese Signatur nicht allein aus der Parameteranzahl ableiten, weshalb der Typ ErrorRequestHandler explizit importiert werden sollte. Ohne diesen Typ tippt man leicht eine Middleware mit drei statt vier Parametern, die Express dann niemals als Error-Handler erkennt, was zu unbehandelten Fehlern fuehrt, die in der Produktion stillschweigend als generischer 500er enden.

Fuer Express typsicher gestaltete Fehlerbehandlung lohnt sich eine kleine Hierarchie von Fehlerklassen, die von einer gemeinsamen Basisklasse mit HTTP-Statuscode erben. Die zentrale Error-Middleware muss dann nur noch pruefen, ob der Fehler eine Instanz dieser Basisklasse ist, und kann den passenden Statuscode und die Fehlermeldung konsistent an den Client zurueckgeben, ohne dass jeder einzelne Handler eigene try/catch-Bloecke mit Statuscodes pflegen muss.


// http-errors.ts — typed error hierarchy for consistent responses
export abstract class HttpError extends Error {
  abstract readonly statusCode: number;
  constructor(message: string) {
    super(message);
    this.name = new.target.name;
  }
}

export class NotFoundError extends HttpError {
  readonly statusCode = 404;
}

export class ValidationError extends HttpError {
  readonly statusCode = 422;
  constructor(message: string, public readonly issues: string[]) {
    super(message);
  }
}

export class UnauthorizedError extends HttpError {
  readonly statusCode = 401;
}

// error-middleware.ts
import type { ErrorRequestHandler } from "express";

export const errorHandler: ErrorRequestHandler = (err, req, res, next) => {
  if (err instanceof HttpError) {
    res.status(err.statusCode).json({ error: err.message, name: err.name });
    return;
  }
  console.error("Unhandled error:", err);
  res.status(500).json({ error: "Internal Server Error" });
};

6. Body und Query mit Zod validieren

Statische Typen aus TypeScript pruefen ausschliesslich zur Kompilierzeit. Zur Laufzeit weiss der Server nicht, ob ein tatsaechlich eingehender Request wirklich der erwarteten Form entspricht, denn req.body bleibt ein rohes JSON-Objekt, unabhaengig davon, was im generischen Typ des Handlers steht. Genau diese Luecke schliesst Zod: Ein Schema beschreibt die erwartete Form zur Laufzeit, und aus diesem Schema laesst sich mit z.infer automatisch der passende TypeScript-Typ ableiten, sodass Laufzeit-Validierung und statischer Typ nie auseinanderlaufen.

Fuer Express bietet sich eine generische Middleware-Fabrik an, die ein Zod-Schema entgegennimmt und bei ungueltigen Daten sofort mit 422 antwortet, bevor der eigentliche Handler ueberhaupt erreicht wird. Der Handler selbst kann sich danach vollstaendig auf den bereits validierten, korrekt typisierten Body verlassen, ohne selbst noch defensive Pruefungen zu schreiben.


// validate.ts — generic Zod validation middleware for Express
import type { RequestHandler } from "express";
import { z, ZodSchema } from "zod";
import { ValidationError } from "./http-errors";

export function validateBody<T extends ZodSchema>(schema: T): RequestHandler {
  return (req, res, next) => {
    const result = schema.safeParse(req.body);
    if (!result.success) {
      next(new ValidationError(
        "Request body validation failed",
        result.error.issues.map((i) => i.message)
      ));
      return;
    }
    req.body = result.data;
    next();
  };
}

const createUserSchema = z.object({
  email: z.string().email(),
  displayName: z.string().min(2).max(80),
});

// Type is inferred, not duplicated by hand
type CreateUserBody = z.infer<typeof createUserSchema>;

router.post("/users", validateBody(createUserSchema), createUser);

7. Async-Handler ohne unhandled Rejections

Express in Version 4 faengt Fehler aus asynchronen Handlern nicht automatisch ab. Wirft eine async-Funktion eine Exception oder lehnt ein Promise ab, landet der Fehler nicht in der Error-Middleware, sondern als unbehandelte Promise-Ablehnung im Prozess, was im schlimmsten Fall den ganzen Node-Prozess zum Absturz bringt. Dieses Verhalten wurde erst in Express 5 korrigiert, weshalb viele Projekte weiterhin einen expliziten Wrapper benoetigen.

Der uebliche TypeScript-Wrapper nimmt eine asynchrone Handler-Funktion entgegen und gibt eine synchrone Funktion zurueck, die intern .catch(next) aufruft. Wichtig dabei: Der Wrapper muss generisch ueber dieselben vier Parameter wie RequestHandler bleiben, sonst gehen bei jeder Verwendung die konkreten Typen von Body, Params und Query verloren, und man landet wieder bei implizitem any.


// async-handler.ts — preserves generic types while catching rejections
import type { Request, Response, NextFunction, RequestHandler } from "express";

type AsyncHandler<P = {}, ResBody = unknown, ReqBody = unknown, ReqQuery = {}> =
  (
    req: Request<P, ResBody, ReqBody, ReqQuery>,
    res: Response<ResBody>,
    next: NextFunction
  ) => Promise<void>;

export function asyncHandler<P, ResBody, ReqBody, ReqQuery>(
  handler: AsyncHandler<P, ResBody, ReqBody, ReqQuery>
): RequestHandler<P, ResBody, ReqBody, ReqQuery> {
  return (req, res, next) => {
    handler(req, res, next).catch(next);
  };
}

router.get(
  "/users/:userId",
  asyncHandler<{ userId: string }, UserResponseBody>(async (req, res) => {
    const user = await userService.findById(req.params.userId);
    if (!user) throw new NotFoundError(`User ${req.params.userId} not found`);
    res.json(user);
  })
);

8. Dependency Injection und typisierte Services

Express selbst bringt keinen eingebauten Mechanismus fuer Dependency Injection mit, im Gegensatz zu Frameworks wie NestJS. Fuer TypeScript mit Express reicht in den meisten Projekten ein einfaches Pattern: Services werden als Klassen mit Konstruktor-Parametern definiert, im Anwendungs-Einstiegspunkt einmal instanziiert und ueber eine Factory-Funktion an die Router weitergereicht, statt globale Singletons zu importieren.

Dieses manuelle Vorgehen bleibt bei kleinen bis mittelgrossen Backends ausreichend, verliert seinen Vorteil aber, sobald Zirkelbezuege zwischen Services entstehen oder Mocking in Tests aufwendig wird. In solchen Faellen lohnt sich der Blick auf ein dediziertes DI-Framework, das bei Express optional bleibt, waehrend es bei anderen TypeScript-Backend-Frameworks wie NestJS von Anfang an vorausgesetzt wird.

Kriterium Express Fastify Hono
Typinferenz aus Schema Nur manuell via Zod-Middleware Eingebaut ueber JSON Schema Manuell via Zod-Validator
Async-Fehler standardmaessig sicher Nein (v4), ja ab v5 Ja Ja
Oekosystem-Groesse Sehr gross, viele Middleware-Pakete Wachsend, gute Kernpakete Klein, aber Web-Standard-kompatibel
Edge-Runtime-Faehigkeit Nur Node.js Node.js, eingeschraenkt Edge Node, Deno, Bun, Cloudflare Workers

9. Express im Vergleich zu Fastify und Hono

Die Wahl zwischen Express, Fastify und Hono ist keine reine Geschmacksfrage, sondern haengt stark davon ab, wie viel eingebaute Typsicherheit ein Team direkt am Framework haben moechte. Express typsicher zu betreiben erfordert mehr manuelle Arbeit als bei Fastify, das JSON Schema und Typinferenz von Haus aus mitbringt. Der grosse Vorteil von Express bleibt jedoch das riesige Oekosystem an Middleware-Paketen, das fuer viele Standardaufgaben bereits fertige, gut getestete Loesungen bereithaelt.

Fuer Teams, die bereits viel Middleware-Code in Express geschrieben haben, lohnt sich meist keine vollstaendige Migration, sondern die konsequente Nachruestung von generischen Typen, Zod-Validierung und einer Fehlerklassen-Hierarchie, wie in diesem Artikel gezeigt. Fuer neue Projekte ohne Legacy-Ballast lohnt sich hingegen ein Blick auf Fastify oder Hono, die von Grund auf mit Typsicherheit im Hinterkopf entworfen wurden.

Mironsoft

TypeScript-Backends, API-Architektur und Typsicherheit im Deployment

Express-Backend ohne durchgaengige Typsicherheit?

Wir haerten bestehende Express-Anwendungen mit generischen Request-Typen, Zod-Validierung und einer sauberen Fehlerklassen-Hierarchie ab, damit Fehler beim Kompilieren auffallen statt beim Kunden in der Produktion.

Typ-Audit

Analyse aller Routen auf implizites any und fehlende generische Typen

Validierung nachruesten

Zod-Schemas fuer Body und Query, automatisch abgeleitete Typen

Fehlerbehandlung

Konsistente HTTP-Fehlerklassen statt verstreuter try/catch-Bloecke

10. Zusammenfassung

TypeScript mit Express typsicher zu gestalten bedeutet vor allem, die generischen Parameter der Handler-Typen aktiv zu nutzen, statt sich auf die impliziten Standardwerte zu verlassen. Request-Params, Response-Body, Request-Body und Query muessen fuer jede Route eigens definiert werden, damit der Compiler falsche Zugriffe schon vor dem Deployment erkennt. Middleware-Ketten profitieren von engen, zusaetzlichen Request-Typen statt globaler Modul-Augmentation, und eine Fehlerklassen-Hierarchie ersetzt verstreute Statuscode-Logik in jedem einzelnen Handler.

Zod schliesst die Luecke zwischen statischer Typpruefung und Laufzeit-Validierung, indem aus einem einzigen Schema sowohl die Laufzeitpruefung als auch der TypeScript-Typ abgeleitet werden. Ein Async-Handler-Wrapper verhindert unbehandelte Promise-Ablehnungen, die in Express 4 sonst zum Prozessabsturz fuehren koennen. Wer diese Bausteine konsequent kombiniert, bekommt ein Express-typsicheres Backend, das nicht an das eingebaute Typsystem von Fastify oder Hono heranreicht, aber vom riesigen Express-Oekosystem profitiert.

TypeScript mit Express: Das Wichtigste auf einen Blick

Generische Handler-Typen

Params, ResBody, ReqBody und ReqQuery explizit angeben statt implizitem any zu vertrauen.

Middleware-Typen

Engere Request-Erweiterungen pro Kette statt globaler Modul-Augmentation fuer alle Routen.

Zod-Validierung

Ein Schema liefert Laufzeitpruefung und via z.infer den passenden TypeScript-Typ gleichzeitig.

Fehlerbehandlung

HttpError-Hierarchie plus ErrorRequestHandler statt verstreuter Statuscode-Logik im Handler.

11. FAQ: TypeScript mit Express typsicher gestalten

1Ist req.body standardmaessig typsicher?
Nein, ohne generische Typen ist req.body any. Erst RequestHandler mit expliziten Typparametern schafft echte Sicherheit.
2Wie typisiere ich Route-Parameter?
Ueber den ersten generischen Parameter von RequestHandler. Parameter sind zur Laufzeit immer Strings.
3Warum kein globales Request-Interface?
Macht ein Feld ueberall verfuegbar, auch ohne durchlaufene Middleware. Ein engerer eigener Typ ist praeziser.
4Fangen async-Handler Fehler automatisch ab?
In Express 4 nicht. Ein Wrapper mit .catch(next) oder Express 5 loest das Problem.
5Wie kombiniere ich Zod mit Express?
Ein Schema validiert zur Laufzeit, z.infer liefert den passenden TypeScript-Typ automatisch mit.
6RequestHandler vs. Router-Typen?
Gleiche generische Parameter. RequestHandler beschreibt eine Funktion, der Router-Typ die Registrierung mehrerer Handler.
7Wie behandle ich Fehler konsistent?
HttpError-Hierarchie plus zentrale ErrorRequestHandler-Middleware, die auf instanceof prueft.
8Brauche ich Dependency Injection?
Nicht zwingend. Manuelles Instanziieren reicht meist, bei Zirkelbezuegen lohnt ein DI-Framework.
9Lohnt sich der Umstieg auf Fastify?
Fuer neue Projekte oft ja. Fuer bestehende Express-Apps ist Nachruesten der Typen meist wirtschaftlicher.
10Reicht @types/express allein?
Nein, das Paket beschreibt nur die API-Struktur. Body, Params und Query muessen selbst typisiert werden.