Contracts-Pakete im TypeScript-Monorepo
Wenn Frontend und Backend ihre eigenen, unabhängig gepflegten Typen für dieselbe API definieren, driften beide Seiten früher oder später auseinander, und der Fehler zeigt sich erst zur Laufzeit beim Kunden. Ein gemeinsam genutztes Contracts-Paket im TypeScript-Monorepo macht solche Abweichungen bereits beim Kompilieren sichtbar.
Inhaltsverzeichnis
- 1. Warum Typen zwischen Frontend und Backend auseinanderdriften
- 2. Das Contracts-Paket als Single Source of Truth
- 3. Zod-Schemas statt reiner Interfaces
- 4. Nutzung im Backend: Validierung an der API-Grenze
- 5. Nutzung im Frontend: Formulare und Response-Parsing
- 6. Versionierung von Contracts bei Breaking Changes
- 7. Grenzen des Ansatzes: Wann Shared Types nicht passen
- 8. Alternativen: OpenAPI-Codegen und tRPC im Vergleich
- 9. Ansätze für Shared Types im Vergleich
- 10. Zusammenfassung
- 11. FAQ
1. Warum Typen zwischen Frontend und Backend auseinanderdriften
In einem klassischen Setup pflegt das Backend seine eigenen Typen für Request- und Response-Objekte, während das Frontend separate Interfaces für dieselben Datenstrukturen definiert. Beide Seiten kompilieren fehlerfrei, obwohl das Backend inzwischen ein Feld umbenannt oder ein Datumsformat geändert hat. Der Fehler fällt erst auf, wenn ein Nutzer ein Formular abschickt und die Antwort im Frontend nicht mehr zum erwarteten Shape passt. Genau dieses Szenario ist der Auslöser dafür, warum Shared Types in einem TypeScript-Monorepo so wertvoll sind.
Der Kerngedanke ist einfach: Statt zwei unabhängige Typdefinitionen für dieselbe Datenstruktur zu pflegen, gibt es genau eine, die beide Seiten importieren. Ändert sich die Struktur, schlägt der TypeScript-Compiler sofort in jedem Paket an, das die veraltete Form noch erwartet. Diese Fehler zeigen sich beim Build, nicht erst beim Kunden in der Produktion. In einem TypeScript-Monorepo mit gemeinsamer Versionsverwaltung ist die technische Voraussetzung dafür bereits gegeben, es fehlt nur die bewusste Entscheidung, ein eigenes Paket für diese Shared Types anzulegen.
In der Praxis zeigt sich der Wert von Shared Types besonders deutlich bei Teams, die schnell iterieren und häufig neue Felder oder Endpunkte hinzufügen. Ohne eine gemeinsame Quelle der Wahrheit entsteht in solchen Teams fast zwangsläufig eine stille Übereinkunft, API-Änderungen zusätzlich in einem Chat-Kanal anzukündigen, weil der Compiler allein keine Warnung liefert. Diese informelle Kommunikation skaliert nicht und versagt zuverlässig, sobald ein Team wächst oder ein neues Mitglied die Konvention noch nicht kennt.
2. Das Contracts-Paket als Single Source of Truth
Die gängige Struktur ist ein eigenständiges Paket, oft packages/contracts genannt, das ausschließlich Typdefinitionen, Schemas und gegebenenfalls kleine Hilfsfunktionen enthält, aber keine Geschäftslogik. Dieses Paket hat bewusst keine Abhängigkeit zu einem Web-Framework wie Express oder zu einem Frontend-Framework wie React, damit es von beiden Seiten ohne unnötigen Ballast importiert werden kann. Für Shared Types in einem TypeScript-Monorepo ist diese Trennung entscheidend, weil ein Contracts-Paket mit Backend-spezifischen Abhängigkeiten im Frontend-Bundle unnötig aufgeblähte Bundle-Größen erzeugen würde.
Innerhalb des Contracts-Pakets werden Typen üblicherweise nach fachlicher Domäne organisiert, etwa user.ts, order.ts und product.ts, statt nach technischer Schicht. Jede Datei exportiert sowohl den TypeScript-Typ als auch, falls Laufzeitvalidierung benötigt wird, das zugehörige Schema. Diese Struktur macht es für neue Teammitglieder einfach, die Quelle der Wahrheit für ein bestimmtes Datenmodell zu finden, ohne durch Backend- oder Frontend-Code suchen zu müssen.
Ein Contracts-Paket sollte außerdem eine eigene, unabhängige Versionierung im Sinne der internen Semver-Disziplin erhalten, selbst wenn es niemals öffentlich auf npm veröffentlicht wird. Das erlaubt, Breaking Changes an Shared Types genauso klar zu kommunizieren wie bei jedem anderen internen Paket in einem TypeScript-Monorepo, statt Änderungen an Contracts implizit als Nebeneffekt anderer Releases zu behandeln.
// packages/contracts/src/order.ts
import { z } from "zod";
// Single source of truth for the Order shape
export const OrderStatusSchema = z.enum([
"pending",
"paid",
"shipped",
"cancelled",
]);
export const OrderSchema = z.object({
id: z.string().uuid(),
customerId: z.string().uuid(),
status: OrderStatusSchema,
totalCents: z.number().int().nonnegative(),
createdAt: z.string().datetime(),
});
// Type is derived from the schema, never written by hand
export type Order = z.infer<typeof OrderSchema>;
export type OrderStatus = z.infer<typeof OrderStatusSchema>;
3. Zod-Schemas statt reiner Interfaces
Ein reines TypeScript-Interface beschreibt nur die Form der Daten zur Kompilierzeit, verschwindet aber vollständig, sobald der Code zu JavaScript kompiliert ist. Für Shared Types, die über eine Netzwerkgrenze hinweg ausgetauscht werden, reicht das nicht aus, weil eine externe Anfrage niemals garantiert dem erwarteten Shape entspricht. Zod löst dieses Problem, indem das Schema zur Laufzeit tatsächlich validiert und der TypeScript-Typ per z.infer direkt aus dem Schema abgeleitet wird, statt separat gepflegt zu werden.
Dieser Ansatz verhindert eine zweite Art von Drift, die reine Shared Types ohne Laufzeitvalidierung nicht lösen: Der Typ kann korrekt sein, während die tatsächlichen Daten zur Laufzeit trotzdem abweichen, etwa weil ein Drittanbieter-Webhook unerwartete Felder liefert. Mit Zod wird jede eingehende Payload gegen dasselbe Schema geprüft, das auch den TypeScript-Typ definiert, wodurch Typsicherheit und Laufzeitsicherheit aus derselben Quelle stammen, statt zwei getrennte Wahrheiten zu sein.
Ein weiterer praktischer Vorteil von Zod gegenüber reinen Interfaces zeigt sich bei Transformationen: Ein Schema kann eingehende Strings automatisch in Zahlen oder Daten umwandeln, während gleichzeitig sichergestellt wird, dass das Ergebnis dem erwarteten Shared-Types-Typ entspricht. Diese Kombination aus Parsen und Validieren in einem einzigen Schritt reduziert zusätzlichen Transformationscode, der sonst separat gepflegt werden müsste.
4. Nutzung im Backend: Validierung an der API-Grenze
Im Backend wird das Contracts-Paket an genau der Stelle importiert, an der eine Anfrage die Systemgrenze überschreitet, also im Request-Handler, bevor die Daten in die Geschäftslogik weitergereicht werden. Der Handler ruft OrderSchema.parse(requestBody) auf, was entweder ein typisiertes Objekt zurückgibt oder eine Exception mit einer detaillierten Fehlerliste wirft. Diese Stelle ist der einzige Ort, an dem unstrukturierte, potenziell falsche Eingaben in strukturierte, typsichere Daten überführt werden, und genau das macht Shared Types im TypeScript-Monorepo praktisch nutzbar, statt nur theoretisch korrekt.
Ein häufiger Fehler ist, die Validierung nur im Frontend durchzuführen und dem Backend zu vertrauen, dass ohnehin nur valide Daten ankommen. Das ignoriert, dass eine API potenziell auch von anderen Clients, mobilen Apps oder direkten HTTP-Aufrufen angesprochen wird, die die Frontend-Validierung gar nicht durchlaufen. Die Validierung mit demselben Zod-Schema im Backend ist daher keine Redundanz, sondern die eigentliche Sicherheitsgrenze, während die Frontend-Validierung primär der Nutzerfreundlichkeit dient.
// apps/api/src/routes/orders.ts
import { OrderSchema } from "@myorg/contracts";
import type { Request, Response } from "express";
export function createOrderHandler(req: Request, res: Response) {
const result = OrderSchema.omit({ id: true, createdAt: true }).safeParse(
req.body
);
if (!result.success) {
// The same schema also shapes the frontend form validation
return res.status(400).json({ errors: result.error.flatten() });
}
const order = createOrder(result.data);
res.status(201).json(order);
}
Ein weiterer Punkt, der in der Praxis oft übersehen wird: Die Fehlerobjekte, die safeParse bei fehlgeschlagener Validierung zurückgibt, sollten selbst Teil des Contracts-Pakets sein, wenn das Frontend sie strukturiert anzeigen will. Ohne ein gemeinsames Format für Validierungsfehler entsteht sonst eine weitere Stelle, an der Frontend und Backend unabhängig voneinander Annahmen treffen, die genauso auseinanderdriften können wie die eigentlichen Shared Types.
5. Nutzung im Frontend: Formulare und Response-Parsing
Im Frontend dient dasselbe Schema zwei Zwecken. Erstens validiert es Formulareingaben, bevor eine Anfrage überhaupt abgeschickt wird, und liefert dem Nutzer sofortiges Feedback statt eines Roundtrips zum Server. Zweitens parst es die Antwort des Backends, um sicherzustellen, dass das, was tatsächlich über das Netzwerk ankommt, dem erwarteten Contract entspricht. Diese zweite Nutzung wird häufig übersehen, ist aber gerade in einem TypeScript-Monorepo mit mehreren Backend-Diensten wichtig, weil ein einzelner Service durchaus von der erwarteten Antwortstruktur abweichen kann.
Der praktische Vorteil zeigt sich besonders bei Refactorings: Wird ein Feld im Contracts-Paket umbenannt, meldet der TypeScript-Compiler jede Stelle im Frontend, die den alten Feldnamen noch verwendet, sofort als Fehler. Ohne Shared Types würde derselbe Fehler unbemerkt bleiben, bis ein Nutzer eine kaputte Seite meldet. Diese sofortige Sichtbarkeit von Breaking Changes ist der eigentliche Produktivitätsgewinn, nicht die eingesparte Tipparbeit bei der Typdefinition selbst.
Für React-Anwendungen, die Formularbibliotheken wie React Hook Form einsetzen, lässt sich das Zod-Schema direkt als Resolver einbinden, sodass keine separate Validierungslogik im Formular-Code entsteht. Dieselbe Instanz des Schemas übernimmt damit sowohl clientseitige Validierung als auch, nach dem Absenden, die Interpretation der Backend-Antwort, ohne dass zwei unterschiedliche Validierungsbibliotheken gepflegt werden müssen.
6. Versionierung von Contracts bei Breaking Changes
Sobald mehrere Frontend-Versionen parallel im Einsatz sind, etwa während eines schrittweisen Rollouts, reicht eine einzige Version des Contracts-Pakets nicht mehr aus. Ein Breaking Change am Order-Schema, das ein Pflichtfeld ergänzt, würde ältere Frontend-Instanzen brechen, die dieses Feld noch nicht senden. Die gängige Lösung ist, neue Felder zunächst optional einzuführen und erst in einer späteren Version verpflichtend zu machen, sobald sichergestellt ist, dass kein aktiver Client mehr die alte Form nutzt.
Für API-Versionen, die parallel unterstützt werden müssen, exportiert das Contracts-Paket teils mehrere Schema-Versionen nebeneinander, etwa OrderSchemaV1 und OrderSchemaV2, statt das alte Schema einfach zu löschen. Diese Praxis erhöht zwar kurzfristig die Komplexität des Contracts-Pakets, verhindert aber, dass Breaking Changes an Shared Types unkontrolliert in Produktion durchschlagen. Wichtig ist, alte Schema-Versionen mit einem klaren Enddatum zu versehen und im Code sichtbar als veraltet zu markieren.
Ein strukturierter Versionierungsprozess wie Changesets eignet sich hervorragend, um genau diese Entscheidungen für ein Contracts-Paket nachvollziehbar zu dokumentieren. Jede Änderung an einem Schema wird dann explizit als Patch, Minor oder Major eingestuft, und das zugehörige Changelog macht sichtbar, welche Konsumenten von einer bestimmten Contracts-Version betroffen sind, bevor ein Team versehentlich mit einer veralteten Schema-Version arbeitet.
7. Grenzen des Ansatzes: Wann Shared Types nicht passen
Shared Types setzen voraus, dass Frontend und Backend im selben TypeScript-Monorepo liegen oder zumindest dasselbe Contracts-Paket über eine private Registry beziehen können. Bei einer Public API, die von externen Drittanbietern konsumiert wird, ist dieser Ansatz ungeeignet, weil externe Konsumenten kein npm-Paket aus einem internen Monorepo installieren können. Hier ist ein sprachunabhängiger Standard wie OpenAPI die bessere Wahl, weil er Client-Code für beliebige Programmiersprachen generieren kann, nicht nur für TypeScript.
Ein weiterer Grenzfall entsteht, wenn Frontend und Backend von komplett unterschiedlichen Teams mit eigenen Release-Zyklen entwickelt werden, ohne dass beide Teams im selben Repository arbeiten. In diesem Fall wird das Contracts-Paket selbst zum Koordinationsaufwand, weil jede Änderung eine Kommunikation zwischen Teams erfordert, die vorher unabhängig voneinander deployen konnten. Für lose gekoppelte Teams ist ein vertragsbasierter Ansatz wie Consumer-Driven Contract Testing oft praktikabler als vollständig geteilte Shared Types.
8. Alternativen: OpenAPI-Codegen und tRPC im Vergleich
OpenAPI-Codegenerierung verfolgt einen anderen Weg zum selben Ziel: Statt handgeschriebener Zod-Schemas beschreibt eine OpenAPI-Spezifikation die API, und ein Generator erzeugt daraus TypeScript-Typen für beide Seiten. Der Vorteil ist Sprachunabhängigkeit und eine etablierte Tooling-Landschaft, der Nachteil ein zusätzlicher Generierungsschritt, der bei jeder API-Änderung ausgeführt werden muss, statt dass die Typen sofort beim Speichern des Contracts-Pakets verfügbar sind.
tRPC geht in die entgegengesetzte Richtung und verzichtet komplett auf Schemas als separates Artefakt. Stattdessen werden Backend-Router-Definitionen direkt als TypeScript-Typ importiert, und der Client leitet daraus vollständige End-to-End-Typsicherheit ab, ganz ohne Codegenerierung oder manuell gepflegtes Contracts-Paket. Dieser Ansatz funktioniert hervorragend innerhalb eines TypeScript-Monorepos mit einem einzigen Backend, stößt aber an Grenzen, sobald mehrere unabhängige Services oder nicht-TypeScript-Clients beteiligt sind.
Ein hybrider Ansatz, der in der Praxis häufig unterschätzt wird: Ein Contracts-Paket mit Zod-Schemas als interne Wahrheit definieren und daraus zusätzlich automatisch eine OpenAPI-Spezifikation generieren, etwa mit der Bibliothek zod-to-openapi. So bleibt die interne Entwicklererfahrung schnell und typsicher, während gleichzeitig eine sprachunabhängige Dokumentation für zukünftige externe Konsumenten entsteht, ohne zwei komplett getrennte Wahrheiten pflegen zu müssen.
9. Ansätze für Shared Types im Vergleich
Die Wahl zwischen manuell gepflegten Contracts-Paketen, OpenAPI-Codegen und tRPC hängt stark von Team-Struktur, Anzahl der Clients und Sprachvielfalt ab.
| Ansatz | Voraussetzung | Runtime-Validierung | Sprachunabhängig |
|---|---|---|---|
| Contracts-Paket mit Zod | Gemeinsames TypeScript-Monorepo | Ja, integriert | Nein, nur TypeScript |
| OpenAPI-Codegen | Gepflegte OpenAPI-Spezifikation | Separat, je nach Generator | Ja, beliebige Sprachen |
| tRPC | Ein Backend, TypeScript überall | Ja, über Zod-Input-Schemas | Nein, nur TypeScript-Clients |
| Consumer-Driven Contracts | Mehrere unabhängige Teams | Über separate Test-Suite | Ja, sprachunabhängig |
Für ein internes TypeScript-Monorepo mit einem eigenen Backend und einem eigenen Frontend ist ein Contracts-Paket mit Zod meist die pragmatischste Lösung, weil es ohne zusätzlichen Codegenerierungsschritt auskommt und Laufzeitvalidierung direkt einschließt. Sobald externe Clients oder andere Programmiersprachen ins Spiel kommen, verschiebt sich die Balance in Richtung OpenAPI oder Consumer-Driven Contracts.
Die Tabelle zeigt außerdem, dass keiner der vier Ansätze in jeder Situation überlegen ist. Ein Team sollte die Entscheidung anhand der tatsächlichen Konsumenten treffen, nicht anhand dessen, welcher Ansatz gerade im Trend liegt, denn eine falsch gewählte Strategie verursacht in einem wachsenden TypeScript-Monorepo deutlich mehr Migrationsaufwand als die anfängliche Entscheidung selbst gekostet hätte.
Mironsoft
TypeScript-Architektur, API-Design und Full-Stack-Typsicherheit
Frontend und Backend driften bei der API auseinander?
Wir bauen Contracts-Pakete mit Zod-Schemas auf, richten Shared Types zwischen Frontend und Backend im TypeScript-Monorepo ein und schließen typische API-Drift-Fehler bereits beim Kompilieren aus.
Contracts-Design
Domänenorientiertes Contracts-Paket mit Zod-Schemas aufsetzen
API-Absicherung
Validierung an jeder API-Grenze mit demselben Schema wie im Frontend
Versionsstrategie
Breaking Changes an Contracts kontrolliert und rückwärtskompatibel einführen
10. Zusammenfassung
Shared Types lösen das Grundproblem, dass Frontend und Backend unabhängig gepflegte Typdefinitionen für dieselbe Datenstruktur haben und dadurch unbemerkt auseinanderdriften. Ein eigenständiges Contracts-Paket im TypeScript-Monorepo, kombiniert mit Zod-Schemas, macht Breaking Changes bereits beim Kompilieren sichtbar und stellt sicher, dass Laufzeitvalidierung und Typdefinition aus derselben Quelle stammen, statt zwei separate Wahrheiten zu sein.
Der Ansatz hat klare Grenzen bei Public APIs mit externen, nicht-TypeScript-Konsumenten und bei lose gekoppelten Teams mit unabhängigen Release-Zyklen. In diesen Fällen sind OpenAPI-Codegen oder Consumer-Driven Contract Testing die passendere Wahl. Für ein internes TypeScript-Monorepo mit eigenem Frontend und Backend bleibt ein handgepflegtes Contracts-Paket jedoch der pragmatischste Weg zu echter End-to-End-Typsicherheit.
Wer heute mit einem einzigen internen Contracts-Paket startet, kann später ohne größeren Umbau zu einer hybriden Lösung mit generierter OpenAPI-Dokumentation wechseln, sobald externe Konsumenten hinzukommen. Diese schrittweise Erweiterbarkeit macht den Einstieg über Shared Types auch für Teams risikoarm, die noch nicht wissen, ob ihre API irgendwann öffentlich geöffnet wird.
Am wichtigsten bleibt, die Validierung mit demselben Schema konsequent an der tatsächlichen Systemgrenze zu platzieren, nicht nur dort, wo es aktuell am bequemsten erscheint.
Dieses eine Prinzip verhindert die überwiegende Mehrheit der API-Drift-Probleme in der Praxis.
Alles Weitere folgt aus dieser einen Entscheidung nahezu von selbst.
Shared Types zwischen Frontend und Backend — Das Wichtigste auf einen Blick
Contracts-Paket
Eigenständiges Paket ohne Framework-Abhängigkeiten, organisiert nach fachlicher Domäne statt technischer Schicht.
Zod statt reiner Interfaces
Typ wird per z.infer aus dem Schema abgeleitet, Laufzeitvalidierung und Typsicherheit stammen aus einer Quelle.
Validierung an der API-Grenze
Backend validiert immer, unabhängig von Frontend-Validierung, weil andere Clients die API ebenfalls ansprechen können.
Grenzen kennen
Bei Public APIs und lose gekoppelten Teams sind OpenAPI-Codegen oder Consumer-Driven Contracts die bessere Wahl.