Eine Quelle der Wahrheit für Typen, Validierung und API-Dokumentation statt doppelter Pflege
Wer TypeScript-Interfaces und JSON Schema getrennt pflegt, verliert früher oder später die Synchronität zwischen beiden: Ein Feld wird im Typ umbenannt, das Schema bleibt beim alten Namen stehen, und ein Konsument validiert gegen Regeln, die nicht mehr zur echten API passen. Werkzeuge wie ts-json-schema-generator und zod-to-json-schema leiten das Schema direkt aus dem TypeScript-Code ab und machen den Typ zur einzigen Quelle der Wahrheit.
Inhaltsverzeichnis
- 1. Das Synchronisationsproblem zwischen Typen und Schema
- 2. ts-json-schema-generator: Schema direkt aus Interfaces ableiten
- 3. Generierung per CLI in die Build-Pipeline einbinden
- 4. Der umgekehrte Weg: zod-Schema als Ausgangspunkt
- 5. Grenzfälle: Mapped Types, Generics und Discriminated Unions
- 6. Generiertes Schema mit AJV zur Laufzeit validieren
- 7. Generierte Schemas in OpenAPI-Dokumente einbinden
- 8. Empfohlener CI-Workflow für generierte Schemas
- 9. Wann sich automatische Schema-Generierung lohnt
- 10. Zusammenfassung
- 11. FAQ
1. Das Synchronisationsproblem zwischen Typen und Schema
In vielen Projekten existieren TypeScript-Interfaces für den internen Gebrauch und ein separates JSON Schema für externe API-Verträge, Validierung eingehender Requests oder OpenAPI-Dokumentation, oft von Hand in zwei verschiedenen Dateien gepflegt. Sobald ein Feld hinzukommt oder sich ein Typ ändert, muss diese Änderung an zwei Stellen nachgezogen werden, und genau das wird in der Praxis regelmäßig vergessen.
Das Resultat ist ein Schema, das der Realität des Codes hinterherhinkt: Ein optionales Feld wird im TypeScript-Typ verpflichtend, im JSON Schema bleibt es weiterhin optional, und ein Client, der sich auf das Schema verlässt, sendet Requests, die zur Laufzeit an einer Stelle scheitern, die eigentlich schon zur Build-Zeit hätte auffallen müssen.
Automatische Generierung dreht diese Beziehung um: Der TypeScript-Typ ist die einzige Quelle, aus der sowohl der Compile-Zeit-Typ als auch das Laufzeit-Schema abgeleitet werden, entweder durch Analyse der TypeScript-AST oder durch eine Bibliothek, die Schema und Typ gemeinsam aus einer einzigen Definition erzeugt.
Dieses Prinzip ist besonders wertvoll an Systemgrenzen, an denen mehrere Teams oder sogar mehrere Unternehmen auf einen gemeinsamen Vertrag angewiesen sind, etwa bei einer öffentlichen API oder einem internen Event-Bus mit vielen Konsumenten. Dort führt jede manuell gepflegte Doppelung des Schemas früher oder später zu Inkonsistenzen, die erst beim Kunden oder in der Produktion auffallen, während eine generierte Quelle diese Klasse von Fehlern bereits im Build ausschließt.
2. ts-json-schema-generator: Schema direkt aus Interfaces ableiten
ts-json-schema-generator analysiert die TypeScript-AST eines Projekts und erzeugt aus einem benannten Interface oder Typalias ein vollständiges JSON Schema, inklusive verschachtelter Typen, Unions, Enums und JSDoc-Kommentaren, die als description-Feld im Schema landen. Der Vorteil gegenüber manuell geschriebenen Interfaces plus separatem Schema: Es gibt nur eine Definition, das Schema ist immer ein reines Ableitungsergebnis.
Die Bibliothek lässt sich sowohl per CLI als auch programmatisch über die Node-API einbinden, was sich für Build-Pipelines eignet, in denen das Schema bei jedem Build automatisch neu generiert und beispielsweise als statische Datei für eine API-Dokumentation abgelegt wird.
// types/order.ts
/** Eine Bestellung im System. */
export interface Order {
/** Eindeutige Bestell-ID, UUID v4 */
id: string;
/** Gesamtbetrag in Cent, immer positiv */
totalCents: number;
status: "pending" | "shipped" | "cancelled";
items: OrderItem[];
}
export interface OrderItem {
sku: string;
quantity: number;
}
3. Generierung per CLI in die Build-Pipeline einbinden
Der CLI-Aufruf braucht den Pfad zur Typdatei, den Namen des Root-Typs und optional einen tsconfig-Pfad für Pfad-Aliase oder strikte Compiler-Optionen. Das erzeugte Schema lässt sich direkt in ein schemas/-Verzeichnis schreiben und dort von Validierungsbibliotheken wie AJV konsumieren.
In CI-Pipelines lohnt sich ein zusätzlicher Diff-Check: Das Schema wird generiert und mit der eingecheckten Version verglichen, ein Unterschied bricht den Build ab. So wird sichergestellt, dass niemand einen Typ ändert, ohne das generierte Schema mit einzuchecken, was Reviewer sofort sehen.
# Schema für den Order-Typ generieren
npx ts-json-schema-generator \
--path types/order.ts \
--type Order \
--tsconfig tsconfig.json \
--out schemas/order.schema.json
# In CI: Drift zwischen Code und eingechecktem Schema erkennen
npx ts-json-schema-generator --path types/order.ts --type Order \
| diff - schemas/order.schema.json || \
(echo "Schema ist veraltet, bitte neu generieren" && exit 1)
4. Der umgekehrte Weg: zod-Schema als Ausgangspunkt
Ein alternativer Ansatz dreht die Reihenfolge um: Statt aus TypeScript-Typen ein Schema abzuleiten, wird zuerst ein zod-Schema definiert, aus dem sowohl der TypeScript-Typ per z.infer als auch das JSON Schema per zod-to-json-schema abgeleitet werden. Dieser Weg hat den Vorteil, dass das zod-Schema gleichzeitig für Laufzeitvalidierung genutzt werden kann, was bei reiner AST-basierter Generierung fehlt.
Für neue Projekte, die ohnehin Laufzeitvalidierung brauchen, ist der zod-First-Ansatz meist die pragmatischere Wahl, weil er Typ, Validierung und Schema aus einer einzigen Definition erzeugt. Für bestehende Codebasen mit etablierten Interfaces ist ts-json-schema-generator oft der Weg mit weniger Umbauaufwand.
import { z } from "zod";
import { zodToJsonSchema } from "zod-to-json-schema";
const OrderSchema = z.object({
id: z.string().uuid(),
totalCents: z.number().int().positive(),
status: z.enum(["pending", "shipped", "cancelled"]),
items: z.array(
z.object({ sku: z.string(), quantity: z.number().int().positive() })
),
});
type Order = z.infer<typeof OrderSchema>;
const jsonSchema = zodToJsonSchema(OrderSchema, "Order");
// jsonSchema ist ein vollständiges JSON-Schema-Dokument
5. Grenzfälle: Mapped Types, Generics und Discriminated Unions
Nicht jeder TypeScript-Typ lässt sich verlustfrei in JSON Schema übersetzen. Mapped Types wie Record<string, T> werden in der Regel zu additionalProperties-Definitionen, was semantisch ähnlich, aber nicht identisch ist. Generics erfordern, dass der Generator entweder eine konkrete Instanziierung des generischen Typs bekommt oder das Generic explizit aufgelöst wird, bevor generiert werden kann.
Discriminated Unions mit einem gemeinsamen Literalfeld wie type: "a" | "b" lassen sich gut als oneOf mit passenden const-Werten abbilden, sowohl ts-json-schema-generator als auch zod-to-json-schema unterstützen dieses Muster zuverlässig, solange die Discriminator-Werte eindeutige String-Literale sind.
type PaymentEvent =
| { type: "charge"; amountCents: number }
| { type: "refund"; amountCents: number; reason: string };
// Wird zu einem oneOf mit const-Discriminator im JSON Schema:
// { "oneOf": [ { "properties": { "type": { "const": "charge" }, ... } }, ... ] }
6. Generiertes Schema mit AJV zur Laufzeit validieren
Das generierte JSON Schema entfaltet seinen Wert erst, wenn es zur Laufzeit tatsächlich verwendet wird, etwa um eingehende Request-Bodies zu validieren, bevor sie in typisierten Code weitergereicht werden. AJV kompiliert das Schema zu einer schnellen Validierungsfunktion und liefert bei Fehlern eine strukturierte Liste der verletzten Regeln.
Wichtig ist, AJV mit strict: true zu betreiben und unbekannte Zusatzfelder in Requests konsequent abzulehnen, sonst schleichen sich stillschweigend Felder ein, die im Typ gar nicht vorgesehen sind, aber trotzdem unbeanstandet durch die Validierung rutschen.
import Ajv from "ajv";
import orderSchema from "../schemas/order.schema.json";
const ajv = new Ajv({ strict: true, allErrors: true });
const validateOrder = ajv.compile(orderSchema);
function parseOrder(raw: unknown): Order {
if (!validateOrder(raw)) {
throw new Error(ajv.errorsText(validateOrder.errors));
}
return raw as Order;
}
7. Generierte Schemas in OpenAPI-Dokumente einbinden
Weil OpenAPI 3.1 eine vollständige Teilmenge von JSON Schema für Schema-Objekte nutzt, lassen sich generierte Schemas direkt unter components.schemas in eine OpenAPI-Spezifikation einbetten, ohne manuelle Übersetzung. Das bedeutet, dass eine automatisch generierte API-Dokumentation immer exakt den tatsächlichen TypeScript-Typen entspricht, ohne dass jemand die Doku separat pflegt.
Für ältere OpenAPI-Versionen wie 3.0, die nicht vollständig JSON-Schema-kompatibel sind, braucht es einen zusätzlichen Übersetzungsschritt, der beispielsweise const-Schlüssel in enum mit einem Element umwandelt, das viele Tools in diesem Ökosystem inzwischen automatisch mitliefern.
8. Empfohlener CI-Workflow für generierte Schemas
Ein robuster Workflow generiert das Schema als Teil des Build-Skripts, checkt die generierte Datei mit ins Repository ein und lässt einen CI-Schritt prüfen, ob die eingecheckte Version noch dem aktuellen Typ entspricht. Das kombiniert die Nachvollziehbarkeit einer versionierten Datei mit der Garantie, dass sie nie veraltet.
Alternativ generieren manche Teams das Schema komplett zur Build-Zeit ohne es einzuchecken, was Repository-Rauschen vermeidet, aber Code-Reviewern die Möglichkeit nimmt, Änderungen am öffentlichen API-Vertrag direkt im Pull-Request-Diff zu sehen, ein Kompromiss, den jedes Team für sich abwägen muss.
9. Wann sich automatische Schema-Generierung lohnt
Für interne Typen, die nie das Node-Prozess-Grenze verlassen, ist ein generiertes JSON Schema meist überflüssiger Aufwand. Sobald ein Typ aber als API-Vertrag mit externen Konsumenten, als Validierungsregel für eingehende Requests oder als Grundlage einer OpenAPI-Dokumentation dient, zahlt sich die automatische Ableitung schnell aus, weil sie Drift zwischen Typ und Schema strukturell unmöglich macht.
Die Wahl zwischen AST-basierter Generierung und zod-First-Ansatz hängt vor allem davon ab, ob bereits Laufzeitvalidierung im Projekt existiert: Wo sie schon da ist, ist zod meist der natürlichere Ausgangspunkt, wo reine Typdefinitionen dominieren, ist ts-json-schema-generator der Weg mit dem geringsten Umbau.
In beiden Fällen lohnt es sich, die Generierung so früh wie möglich in den Entwicklungsworkflow einzubauen, statt sie erst nachträglich einzuführen, wenn bereits mehrere API-Verträge manuell und inkonsistent gepflegt werden. Je früher ein Team diese Automatisierung etabliert, desto geringer der einmalige Migrationsaufwand und desto größer der langfristige Nutzen für alle Konsumenten der Schnittstelle.
| Merkmal | ts-json-schema-generator | zod-to-json-schema | Manuelles Schema |
|---|---|---|---|
| Ausgangspunkt | bestehende TS-Typen/Interfaces | zod-Schema-Definition | separate JSON-Datei |
| Laufzeitvalidierung inklusive | nein, nur Schema | ja, direkt über zod | nein |
| JSDoc-Kommentare übernommen | ja, als description | teilweise über .describe() | manuell |
| Aufwand bei bestehendem Code | gering | mittel, Migration zu zod nötig | hoch, dauerhafte Doppelpflege |
| Discriminated Unions | als oneOf mit const | als oneOf mit literal | manuell nachgebaut |
Mironsoft
TypeScript-Migration, Typsicherheit und Team-Onboarding
JavaScript-Codebasis ohne Typsicherheit, aber keine Zeit für eine Rundum-Migration?
Wir migrieren bestehende JavaScript-Projekte schrittweise zu TypeScript, richten strikte Compiler-Einstellungen sauber ein und bringen Teams mit Code-Reviews und Style-Guides auf denselben Typsicherheits-Stand.
Migrations-Fahrplan
Schrittweise JS-zu-TS-Migration ohne Big-Bang-Risiko planen und umsetzen.
Strict-Mode-Einführung
tsconfig.json, ESLint-Regeln und CI-Checks für dauerhafte Typsicherheit aufsetzen.
Team-Onboarding
Entwickler mit Workshops und Code-Reviews in TypeScript-Best-Practices einarbeiten.
10. Zusammenfassung
JSON Schema Generierung
AST-Ansatz
ts-json-schema-generator leitet Schema aus bestehenden Interfaces ab
zod-Ansatz
Ein Schema erzeugt Typ, Validierung und JSON Schema zugleich
Laufzeit-Check
AJV kompiliert generiertes Schema zu einer schnellen Validierfunktion
CI-Sicherung
Diff-Check verhindert veraltete, eingecheckte Schema-Dateien