Zweiweg-Synchronisierung von Schema und TypeScript-Typen
Fazit: Typen und Schemas decken je ein Segment ab: eines zur Kompilierzeit, eines zur Laufzeit. Die Frage ist nicht, welches man wählt, sondern wie man beide automatisch synchron hält.

Kurz gesagt: Legen Sie zuerst eine Wahrheitsquelle fest – schema-first, wenn das Backend den Vertrag besitzt, types-first im zod-Stil, wenn das Frontend es tut – und generieren Sie dann die andere Kopie und vergleichen Sie sie in CI; schreiben Sie nur Semantik von Hand, die der Generator nicht ausdrücken kann, etwa Branded Types.
Wenn Sie beide Definitionen von Hand schreiben, driften sie auseinander. Der Preis der Drift ist „es kompiliert, und dann kommt zur Laufzeit ein 500“.
1. Warum eine einzige Kopie nicht funktioniert
| Fähigkeit | TypeScript-Typen | JSON Schema |
|---|---|---|
| Prüfung zur Kompilierzeit | ✓ | ✗ |
| Laufzeitvalidierung | ✗ (Typen werden gelöscht) | ✓ |
| Sprachübergreifende Nutzung | ✗ | ✓ (Java/Go/Python können es alle lesen) |
| IDE-Vervollständigung und Refactoring | ✓ | Mäßig |
| Dokumentationsgenerierung | Mäßig | ✓ (das OpenAPI-Ökosystem) |
Die Eingabe einer API kommt aus dem Netz und ist stets unknown, Laufzeitvalidierung lässt sich also nicht überspringen; und beim Schreiben von Geschäftscode brauchen Sie trotzdem Typvervollständigung.
Der vernünftige Ansatz ist, eine als Wahrheitsquelle zu wählen und die andere zu generieren.
2. Weg A: schema-first
Geeignet für: vom Backend getriebene Arbeit, sprachübergreifende Nutzung und öffentliche Verträge.
pnpm add -D json-schema-to-typescript
import { compileFromFile } from 'json-schema-to-typescript';
import { writeFileSync } from 'node:fs';
/**
* Generate TypeScript type definitions from a JSON Schema.
* @param schemaPath Path to the schema file
* @param outputPath Path to the output file
* @returns A Promise that resolves when generation is finished
*/
async function generateTypes(schemaPath: string, outputPath: string): Promise<void> {
const typeText = await compileFromFile(schemaPath, {
bannerComment: '/* Auto-generated from the schema. Do not edit by hand. */',
additionalProperties: false,
style: { singleQuote: true },
});
writeFileSync(outputPath, typeText, 'utf8');
}
Einige Konventionen der Ausgabe sind beachtenswert:
required: ["id"]→id: string; Schlüssel, die nicht inrequiredstehen →id?: string;"type": ["string", "null"]→string | null;enum→ eine Union von Literaltypen, weit präziser alsstring;additionalProperties: falsebestimmt, ob eine Indexsignatur generiert wird.
Es gibt auch eine Zero-Build-Option, json-schema-to-ts, die direkt auf Typ-Ebene inferiert:
import type { FromSchema } from 'json-schema-to-ts';
const userSchema = {
type: 'object',
properties: { id: { type: 'string' }, age: { type: 'integer' } },
required: ['id'],
additionalProperties: false,
} as const;
type User = FromSchema<typeof userSchema>;
// { id: string; age?: number }
Der Vorteil ist kein Generierungsschritt und Typen, die immer mit dem Schema synchron sind; der Nachteil ist, dass das Schema als as const geschrieben werden muss und komplexe $ref-Szenarien langsam kompilieren.
3. Weg B: types-first
Geeignet für: vom Frontend getriebene Arbeit mit Bibliotheken wie zod / valibot, bei denen „das Schema der Typ ist“.
import { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';
export const userSchema = z.object({
id: z.string().min(1),
name: z.string().min(1).max(64),
age: z.number().int().min(0).max(150).optional(),
tags: z.array(z.string()).default([]),
});
export type User = z.infer<typeof userSchema>;
/** The contract published to other languages */
export const userJsonSchema = zodToJsonSchema(userSchema, {
target: 'jsonSchema2020-12',
$refStrategy: 'none',
});
Hier spielt zod beide Rollen – „Typdefinition“ und „Laufzeitvalidierung“ – und JSON Schema ist nur ein Exportartefakt. Das ist insgesamt der geringste Aufwand, aber behalten Sie zwei Dinge im Sinn:
z.inferund das generierte JSON Schema bildendefault,transformundrefinenicht exakt ab;- Das generierte Schema kann
$refunddefinitionsenthalten, prüfen Sie vor der Veröffentlichung, ob es zur Ziel-Spezifikation passt.
4. Auch die Gegenrichtung muss geprüft werden
Unabhängig vom gewählten Weg ergänzen Sie eine CI-Prüfung: Die generierten Artefakte müssen zu den Dateien im Repository passen.
# Generate into a temp directory and compare; fail CI on any difference
node scripts/generate-types.mjs --out .tmp/types.d.ts
diff -u src/types/api.d.ts .tmp/types.d.ts
Ohne diesen Schritt weiß drei Monate später niemand, ob api.d.ts aktuell ist.
5. Vier häufige Fallen
5.1 optional und nullable sind nicht dasselbe
type A = { name?: string }; // the key may be absent
type B = { name: string | null }; // the key must exist, but the value may be null
In Schema-Begriffen:
{ "properties": { "name": { "type": "string" } } } // optional
{ "required": ["name"], "properties": { "name": { "type": ["string", "null"] } } } // nullable
Generatoren behandeln die beiden Fälle völlig unterschiedlich. Der Vertrag muss festlegen, welchen Sie meinen; sonst stürzt data.name?.length im Frontend bei null ab.
5.2 Weiterentwicklung von Enums
type Status = 'pending' | 'paid' | 'refunded';
Fügt das Backend 'cancelled' hinzu und werden die Frontend-Typen nicht aktualisiert, fällt der Code in den default-Zweig.
Enumartige Felder sollten unbekannte Werte ausdrücklich behandeln:
function normalizeStatus(raw: string): Status | 'unknown' {
return raw === 'pending' || raw === 'paid' || raw === 'refunded' ? raw : 'unknown';
}
5.3 Daten und Geld dürfen nicht einfach string verwenden
Im Schema ist format: "date-time" nur eine Annotation; in TypeScript ist es nur string.
Brauchen Sie eine stärkere Garantie, verwenden Sie einen „Branded Type“:
type IsoDateTime = string & { readonly __brand: 'IsoDateTime' };
/**
* Mark a string as an ISO timestamp.
* @param value A string whose format has already been verified
* @returns The branded timestamp string
*/
function asIsoDateTime(value: string): IsoDateTime {
return value as IsoDateTime;
}
5.4 Große Ganzzahlen
type: "integer" ist nicht gleichbedeutend mit number, sobald Werte 2^53 übersteigen. Schreiben Sie es im Vertrag als:
{ "type": "string", "pattern": "^[0-9]+$", "description": "Order number; exceeds 2^53, must be a string" }
6. Ein vorgeschlagener Einführungsprozess
- Den Vertrag definieren: Beginnen Sie damit, die Request-/Response-Struktur als Schema (oder zod) zu schreiben;
- Typen generieren:
.d.tsin CI generieren und ins Repository committen, damit Unterschiede leicht zu prüfen sind; - Validatoren generieren: Dasselbe Schema zu einer Ajv-Validierungsfunktion kompilieren und an jedem Server-Einstiegspunkt aufrufen;
- Dokumentation generieren: Markdown oder OpenAPI aus dem Schema erzeugen, damit die Doku nie von Hand veraltet;
- Regression: API-Fixtures durch „Typprüfung“ und „Schema-Validierung“ laufen lassen; nur beides zu bestehen zählt.
7. Checkliste
- [ ] Es gibt genau eine Wahrheitsquelle; die andere Kopie wird von einem Skript generiert;
- [ ] Das Generierungsskript läuft in CI, und jede Artefaktabweichung lässt den Build scheitern;
- [ ]
optionalundnullablesind im Vertrag ausdrücklich unterschieden; - [ ] Enum-Felder haben einen Fallback für unbekannte Werte;
- [ ] Große Ganzzahlen, Geld und Zeit haben ausdrückliche Konventionen in Schema und Typen;
- [ ] Generierte Typdateien tragen einen Header „nicht von Hand bearbeiten“.
FAQ
Wie entscheide ich zwischen schema-first und types-first?
Wählen Sie schema-first, wenn das Backend den Vertrag besitzt, sprachübergreifende Nutzung braucht oder den Vertrag öffentlich veröffentlicht, und generieren Sie Typen mit json-schema-to-typescript. Wählen Sie eine zod-artige Bibliothek „Schema ist der Typ“, wenn das Frontend führt und Typen und Validierung einmal definiert haben will, und exportieren Sie dann JSON Schema. Beide Wege brauchen einen CI-Schritt, der generierte Artefakte mit den Repository-Dateien vergleicht.
Warum driften handgeschriebene Typ- und Schemadefinitionen immer auseinander?
Typen werden zur Kompilierzeit gelöscht, während Schemas nur zur Laufzeit wirken, und die beiden werden von verschiedenen Personen zu verschiedenen Zeiten bearbeitet. Ohne Generierung und Vergleich wird eine Kopie aktualisiert, während die andere stehen bleibt, was „es kompiliert, und dann kommt zur Laufzeit ein 500“ erzeugt.
Sollte ich die generierten Typdateien ins Repository committen?
Ja. Durch das Committen können Reviewer Vertragsänderungen sehen, und CI kann frische Generierungsausgabe mit den Repository-Dateien vergleichen und bei jeder Abweichung scheitern. Lassen Sie die Artefakte nur in einem lokalen Ordner oder Build-Cache, weiß drei Monate später niemand, ob sie aktuell sind.