Zum Inhalt springen

Contract-First-API-Design mit OpenAPI und TypeScript

Lass deine API-Spezifikation nicht von der Implementierung abdriften: erst den Vertrag definieren, Typen für beide Seiten generieren, Compiler prüfen lassen.

5 Min. Lesezeit
OpenAPI-YAML-Spezifikation neben generierten TypeScript-Typen, die Contract-First-API-Design zeigen

Die meisten Teams schreiben zuerst die API und dokumentieren sie später. Die OpenAPI-Spezifikation wird zu einer Momentaufnahme der Realität nach bestem Wissen — zum Zeitpunkt der Erstellung korrekt, mit dem nächsten PR veraltet. Clients bekommen das zu spüren. Die Spezifikation wird zu Rauschen, dem niemand vertraut.

Contract-First-Entwicklung dreht das um: Definiere die API-Form in YAML, bevor du einen einzigen Handler schreibst, und generiere dann die Typen aus diesem Vertrag. Server und Client arbeiten gegen dieselbe Single Source of Truth, und der Compiler sagt dir, wenn sie auseinandergedriftet sind.

Warum Code-First scheitert

Der Drift ist anfangs subtil. Ein Feld wird im Handler umbenannt, aber nicht in der Spezifikation. Eine Response-Eigenschaft wird optional, aber der Client geht davon aus, dass sie immer vorhanden ist. Ein neuer Query-Parameter taucht im Code auf, den niemand dokumentiert hat. Nach sechs Monaten ist die Spezifikation reine Dekoration.

tstypescript
// ❌ Code-first: spec and implementation evolve independently
// spec says: { userId: string, name: string }
// handler returns:
return {
  id: user.id,         // renamed — spec still says userId
  fullName: user.name, // renamed — spec still says name
  role: user.role,     // undocumented addition
};
 
// ✅ Contract-first: types derived from the spec — renaming the spec breaks the build
return {
  userId: user.id,
  name: user.name,
} satisfies paths["/users/{id}"]["get"]["responses"]["200"]["content"]["application/json"];

Die zweite Version erfordert keine Disziplin — sie erfordert, dass der Compiler den Vertrag durchsetzt.

Den Vertrag in OpenAPI definieren

Beginne mit der Spezifikation. Schreibe sie vor jeglichem Handler-Code. Halte sie im Repository-Root, wo sie sichtbar ist und zusammen mit dem Code, den sie beschreibt, versioniert wird.

ymlyaml
# openapi.yaml
openapi: "3.1.0"
info:
  title: Users API
  version: "1.0.0"
paths:
  /users/{id}:
    get:
      operationId: getUserById
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: User found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/UserProfile"
        "404":
          description: User not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
components:
  schemas:
    UserProfile:
      type: object
      required: [id, name, email, createdAt]
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
        email:
          type: string
          format: email
        createdAt:
          type: string
          format: date-time
    ErrorResponse:
      type: object
      required: [code, message]
      properties:
        code:
          type: string
        message:
          type: string

Dieses YAML ist die kanonische Definition. Alles, was folgt, wird daraus abgeleitet — nicht umgekehrt.

TypeScript-Typen aus der Spezifikation generieren

openapi-typescript verwandelt dein YAML in ein vollständig typisiertes TypeScript-Modul. Keine handgeschriebenen Interfaces, keine Übertragungsfehler.

shbash
# Install once
npm install -D openapi-typescript
 
# Regenerate after every spec change
npx openapi-typescript ./openapi.yaml -o ./src/types/api.d.ts

Das Ergebnis ist ein paths-Typ, der die gesamte Struktur deiner Spezifikation abbildet. Jede Route, jede Methode, jeder Request- und Response-Body ist typisiert — einschließlich optionaler Felder, diskriminierter Unions und tief verschachtelter Schemas.

~

Committe das generierte api.d.ts in dein Repository und regeneriere es in CI. Ein PR, der die Spezifikation ändert, ohne die Typen neu zu generieren, schlägt beim Type-Check fehl und erkennt den Drift, bevor er gemergt wird.

Den Server-Handler typisieren

Mit generierten Typen wird der Rückgabetyp eines Handlers aus der Spezifikation abgeleitet, statt von Hand deklariert zu werden. Sagt die Spezifikation, dass ein Feld erforderlich ist, und du lässt es weg, bricht der Build.

tstypescript
import type { paths } from "@/types/api";
import type { RequestHandler } from "express";
 
type GetUserResponse =
  paths["/users/{id}"]["get"]["responses"]["200"]["content"]["application/json"];
 
type GetUserError =
  paths["/users/{id}"]["get"]["responses"]["404"]["content"]["application/json"];
 
export const getUserById: RequestHandler<{ id: string }> = async (req, res) => {
  const user = await userRepository.findById(req.params.id);
 
  if (!user) {
    const body: GetUserError = {
      code: "USER_NOT_FOUND",
      message: `No user with id ${req.params.id}`,
    };
    return res.status(404).json(body);
  }
 
  const body: GetUserResponse = {
    id: user.id,
    name: user.name,
    email: user.email,
    createdAt: user.createdAt.toISOString(),
  };
 
  res.status(200).json(body);
};

Benenne name in der Spezifikation in fullName um, und dieser Handler kompiliert nicht mehr, bis er aktualisiert wird. Das ist die Feedback-Schleife, die Code-First-Entwicklung dir niemals geben kann.

Einen typsicheren Client bauen

Derselbe generierte paths-Typ funktioniert auch auf dem Client. Keine separaten Interface-Dateien, kein manuell gepflegter UserProfile-Typ, der über Pakete hinweg aus dem Sync gerät.

tstypescript
import type { paths } from "@/types/api";
 
type UserProfile =
  paths["/users/{id}"]["get"]["responses"]["200"]["content"]["application/json"];
 
async function getUser(id: string): Promise<UserProfile> {
  const response = await fetch(`/api/users/${id}`, {
    headers: { Accept: "application/json" },
  });
 
  if (response.status === 404) {
    throw new NotFoundError(`User ${id} does not exist`);
  }
 
  if (!response.ok) {
    throw new ApiError(`Unexpected status ${response.status}`);
  }
 
  return response.json() as Promise<UserProfile>;
}

Für strengere Garantien umhüllt openapi-fetch die native fetch-API mit vollständiger Typprüfung für jeden Parameter, jeden Header und jede Response — einschließlich der Frage, welche Statuscodes für eine gegebene Operation gültig sind.

Validierung zur Laufzeit

TypeScript-Typen verschwinden zur Laufzeit. Ein Client mit einer veralteten Spec-Version, ein falsch konfigurierter Proxy oder ein Upstream-Bug können trotzdem fehlerhafte Daten liefern. Ergänze Laufzeitvalidierung an der Grenze.

tstypescript
import { z } from "zod";
 
// Mirror the OpenAPI schema with a Zod schema — or use openapi-zod-client to generate it
const userProfileSchema = z.object({
  id: z.string().uuid(),
  name: z.string().min(1),
  email: z.string().email(),
  createdAt: z.string().datetime(),
});
 
export function parseUserProfile(raw: unknown) {
  const result = userProfileSchema.safeParse(raw);
 
  if (!result.success) {
    throw new ValidationError(
      "User profile response did not match contract",
      result.error.issues,
    );
  }
 
  return result.data;
}
i

Tools wie openapi-zod-client und zod-openapi können Zod-Schemas direkt aus deiner OpenAPI-Spezifikation generieren, sodass die Laufzeitvalidierung ohne manuellen Übersetzungsschritt mit dem Vertrag synchron bleibt.

Den Workflow in CI durchsetzen

Contract-First funktioniert nur, wenn die Spezifikation immer vor dem Code aktualisiert wird. Setze es strukturell durch, damit es keine Konvention ist, die unter Termindruck erodiert.

ymlyaml
# .github/workflows/api-contract.yml
name: API Contract Check
on: [push, pull_request]
 
jobs:
  contract:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
 
      - name: Lint OpenAPI spec
        run: npx @redocly/cli lint openapi.yaml
 
      - name: Regenerate types
        run: npx openapi-typescript ./openapi.yaml -o ./src/types/api.d.ts
 
      - name: Fail if types are out of sync
        run: |
          git diff --exit-code src/types/api.d.ts || \
          (echo "Regenerate types locally: npx openapi-typescript ./openapi.yaml -o ./src/types/api.d.ts" && exit 1)
 
      - name: Type check
        run: npx tsc --noEmit

Ein PR, der die Spezifikation aktualisiert, ohne die Typen neu zu generieren, scheitert am Diff-Check. Ein Handler, der den generierten Vertrag bricht, scheitert an tsc --noEmit. Beides erfordert keinen Reviewer, der es bemerkt — was bedeutet, dass beides bei jedem PR erwischt wird, nicht nur bei denen, bei denen jemand daran denkt hinzuschauen.

Die wichtigsten Erkenntnisse

  1. Code-First-Spezifikationen driften by design — nachträglich geschriebene Dokumentation spiegelt die Absicht wider, nicht die Realität. Definiere den Vertrag, bevor du Handler schreibst.
  2. Generieren statt transkribieren — handgeschriebene TypeScript-Interfaces für API-Typen sind ein Wartungsrisiko. Leite sie aus einer einzigen Source of Truth ab.
  3. Typen gelten nur zur Compile-Zeit — kombiniere generierte Typen mit Zod-Laufzeitvalidierung an Vertrauensgrenzen; geh nicht davon aus, dass gut typisierter Code vor fehlerhaften Eingaben sicher ist.
  4. Eine Spezifikation, zwei Konsumenten — derselbe paths-Typ bedient Server-Handler und Client-Fetch-Wrapper und garantiert, dass beide Seiten dieselbe Sprache sprechen.
  5. Mach Drift unmöglich, nicht nur verboten — eine CI-Pipeline, die Typen regeneriert und tsc ausführt, macht Disziplin überflüssig, indem sie Nichteinhaltung zu einem Build-Fehler macht.
Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX