Jedes Mal, wenn deine API einen Request empfängt, validierst du ihn. Jedes Mal, wenn eine Funktion ein Argument von einem externen System erhält, prüfst du es. Machst du das manuell, kann ein einzelner Endpoint mehr Validierungscode als Geschäftslogik ansammeln.

Das ist die versteckte Steuer der Runtime Contracts. Du brauchst sie, weil das Type-System lügt: JSON über HTTP, Datenbankfelder und Umgebungsvariablen kommen zur Laufzeit alle als untypisierte Daten an. Aber wenn du sie mit handgeschriebenen if-Anweisungen implementierst, verwandeln sich einfache Funktionen in unlesbare Mauern aus defensivem Code.

Es gibt einen besseren Weg.

Was Runtime Contracts eigentlich sind

Ein Runtime Contract ist eine Garantie, die eine Funktion über ihre Inputs und Outputs gibt. Übergibst du valide Daten, erhältst du valide Daten zurück. Verletzt du den Contract, schlägt die Funktion sofort mit einem klaren Fehler fehl.

Das ist Design by Contract, und es gibt es schon seit Eiffel. Die Idee ist nicht neu. Was neu ist, ist das Tooling, mit dem du Contracts ausdrücken kannst, ohne für jedes Feld einen Validator zu schreiben.

Der zentrale Einblick: Contracts sind Schemas, kein Code. Ein Schema beschreibt Shape, Constraints und Beziehungen. Code, der diese Dinge manuell prüft, ist nur ein Schema, das in der falschen Sprache geschrieben wurde.

Die Boilerplate-Falle

Hier sieht man, wie manuelle Validierung in der Praxis aussieht. Du hast eine Funktion, die eine Nutzer-Registrierungsanfrage verarbeitet:

function createUser(payload: unknown) {
  if (!payload || typeof payload !== "object") {
    throw new Error("Invalid payload");
  }
  if (!("email" in payload) || typeof (payload as any).email !== "string") {
    throw new Error("email is required and must be a string");
  }
  if (!(payload as any).email.includes("@")) {
    throw new Error("email must be valid");
  }
  if (!("age" in payload) || typeof (payload as any).age !== "number") {
    throw new Error("age is required and must be a number");
  }
  if ((payload as any).age < 13 || !Number.isInteger((payload as any).age)) {
    throw new Error("age must be an integer of at least 13");
  }
  if (
    "tags" in payload &&
    (!Array.isArray((payload as any).tags) ||
      !(payload as any).tags.every((t: any) => typeof t === "string"))
  ) {
    throw new Error("tags must be an array of strings");
  }
  // ... finally, the actual work
}

Das ist ermüdend, fehleranfällig und unvollständig. Hast du geprüft, dass email einem echten E-Mail-Format entspricht? Dass age nicht NaN ist? Dass die tags-Elemente nicht-leere Strings sind? Wahrscheinlich nicht, weil du müde geworden bist.

Und das ist nur eine Funktion. Multipliziere das mit jeder Boundary in deinem System.

Wie deklarative Schemas Boilerplate ersetzen

Anstatt Validatoren zu schreiben, beschreibe das Shape und lass eine Library es durchsetzen.

import { z } from "zod";

const UserSchema = z.object({
  email: z.string().email(),
  age: z.number().int().min(13),
  tags: z.array(z.string().min(1)).optional(),
});

function createUser(payload: unknown) {
  const user = UserSchema.parse(payload);
  // user is typed as { email: string; age: number; tags?: string[] }
  // actual business logic goes here
}

Das Schema ist die Dokumentation. Es ist auch der Validator, die Typdefinition und der Fehlermeldungs-Generator. Änderst du das Schema an einer Stelle, aktualisiert sich der Contract überall dort, wo er verwendet wird.

Das ist kein Zauber. Die Library durchläuft das Schema zur Laufzeit und wendet die Prüfungen an. Der Unterschied besteht darin, dass die Prüfungen aus kleinen, wiederverwendbaren Primitiven komponiert werden, statt jedes Mal von Hand aufgeschrieben zu werden.

Contracts an Funktions-Boundaries

Die wahre Stärke entfaltet sich, wenn du Schemas direkt an Funktions-Inputs und -Outputs anhängst. Du willst, dass der Contract an der Boundary lebt, nicht in die Implementierung hineinleckt.

function withContract<TInput, TOutput>(
  inputSchema: z.ZodSchema<TInput>,
  outputSchema: z.ZodSchema<TOutput>,
  handler: (input: TInput) => TOutput
) {
  return (rawInput: unknown): TOutput => {
    const input = inputSchema.parse(rawInput);
    const output = handler(input);
    return outputSchema.parse(output);
  };
}

const UserResponseSchema = z.object({
  id: z.string().uuid(),
  email: z.string().email(),
});

const createUser = withContract(
  UserSchema,
  UserResponseSchema,
  (user) => {
    // zero validation code here
    return db.users.create(user);
  }
);

Jetzt enthält der Funktions-Body null Validierungscode. Der Contract wird am Rand durchgesetzt, und der Compiler kennt die Typen im Inneren trotzdem, weil das Schema sie ableitet.

Wo das an seine Grenzen stößt: Performance, Inference und Over-Validation

Deklarative Schemas sind nicht umsonst. Du solltest die Trade-offs kennen, bevor du sie einführst.

Performance ist das offensichtliche Problem. Das Parsen eines komplexen Schemas ist langsamer als ein paar if-Prüfungen. In der Praxis ist der Unterschied für die meisten APIs vernachlässigbar. Zod ist für einfache Objekte ungefähr 10-mal langsamer als handgeschriebene Prüfungen. Für die meisten HTTP-Handler sind das Mikrosekunden Unterschied. Wenn du Millionen von Datensätzen in einer heißen Schleife validierst, benchmark zuerst.

Type-Inference-Lücken können dich ebenfalls beißen. Abgeleitete Typen aus Schemas sind manchmal strenger oder lockerer als das, was du von Hand schreiben würdest. Möglicherweise musst du Custom Types definieren und das Schema anweisen, sie zu matchen, statt umgekehrt.

Over-Validation ist der stille Killer. Nicht jede Funktion braucht einen Runtime Contract. Interne Funktionen, die Daten erhalten, die bereits an der API-Boundary validiert wurden, müssen nicht noch einmal geprüft werden. Setze Contracts an System-Boundaries ein, nicht bei jeder Hilfsfunktion.

Wie du das implementierst, ohne deinen Codebase neu zu schreiben

Du brauchst keine Big-Bang-Migration. Fang mit deinen API-Entry-Points an.

  1. Wähle eine extern sichtbare Funktion oder einen Route Handler.
  2. Schreibe ein Schema für ihren Input.
  3. Ersetze die manuelle Validierung durch Schema-Parsing.
  4. Lass das Schema den TypeScript-Typ ableiten. Lösche dein handgeschriebenes Interface.

Mach das für einen Endpoint, dann einen Service, dann eine Domain. Innerhalb einer Woche wirst du feststellen, dass deine Test-Suites schrumpfen, weil du keine Validation-Edge-Cases mehr testen musst, die das Schema bereits abdeckt.

Wenn du TypeScript nicht verwendest, lässt sich das Pattern übertragen. Python hat Pydantic. Go hat go-playground/validator. Rust hat validator. Das Prinzip ist das gleiche: Beschreibe den Contract, generiere die Prüfungen.

FAQ

Ersetzen Runtime Contracts Unit Tests?

Nein. Sie ersetzen den Teil der Unit Tests, der Input-Validation prüft. Du testest weiterhin Geschäftslogik, Error Handling und Integrationsverhalten.

Was ist mit Custom Validation Rules?

Jede ernsthafte Schema-Library unterstützt Custom Refinements. Verwende sie sparsam. Wenn deine Custom Rule länger als fünf Zeilen ist, gehört sie wahrscheinlich in den Funktions-Body, nicht in das Schema.

Sollte ich auch auf dem Rückweg validieren?

Ja, für kritische Funktionen. Ein Output Contract fängt Bugs, bei denen deine Geschäftslogik fehlerhafte Daten produziert. Es ist billiger, an der Quelle zu scheitern, als schlechte Daten in einen anderen Service wandern zu lassen.

Bottom line

Runtime Contracts sind nicht verhandelbar für Systeme, die untypisierte Daten berühren. Die einzige Wahl besteht darin, ob du sie mit Boilerplate oder mit einer Schema-Library bezahlst.

Die Schema-Library kostet dich eine Dependency und einen kleinen Performance-Overhead. Boilerplate kostet dich Wartbarkeit, Korrektheit und deinen Verstand.

Nimm die Dependency.