Sincronizzazione bidirezionale tra Schema e tipi TypeScript
In sintesi: Tipi e schema coprono ciascuno un segmento: uno in fase di compilazione, uno a runtime. La domanda non è quale scegliere, ma come tenere i due sincronizzati automaticamente.

In sintesi: scegli prima un'unica fonte di verità — schema-first quando il backend possiede il contratto, types-first in stile zod quando lo possiede il frontend — poi genera l'altra copia e confrontala in CI; scrivi a mano solo la semantica che il generatore non può esprimere, come i branded type.
Se scrivi a mano entrambe le definizioni, andranno alla deriva. Il prezzo della deriva è "compila, poi restituisce 500 a runtime".
1. Perché tenere una sola copia non funziona
| Capacità | Tipi TypeScript | JSON Schema |
|---|---|---|
| Controllo in fase di compilazione | ✓ | ✗ |
| Validazione a runtime | ✗ (i tipi vengono cancellati) | ✓ |
| Uso tra linguaggi | ✗ | ✓ (Java/Go/Python possono tutti leggerlo) |
| Completamento e refactoring nell'IDE | ✓ | Così così |
| Generazione della documentazione | Così così | ✓ (l'ecosistema OpenAPI) |
L'input di un'API viene dalla rete ed è sempre unknown, quindi la validazione a runtime non può essere saltata; e mentre scrivi codice di business hai comunque bisogno del completamento dei tipi.
L'approccio sensato è scegliere uno come fonte di verità e generare l'altro.
2. Percorso A: schema first
Adatto a: lavoro guidato dal backend, uso tra linguaggi, e contratti pubblici.
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');
}
Alcune convenzioni nell'output valgono la pena di essere notate:
required: ["id"]→id: string; le chiavi non elencate inrequired→id?: string;"type": ["string", "null"]→string | null;enum→ un'unione di tipi letterali, molto più precisa distring;additionalProperties: falsedetermina se viene generata una index signature.
C'è anche un'opzione senza build, json-schema-to-ts, che inferisce direttamente a livello di tipo:
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 }
Il vantaggio è nessun passo di generazione e tipi sempre sincronizzati con lo schema; lo svantaggio è che lo schema deve essere scritto con as const, e gli scenari complessi con $ref compilano lentamente.
3. Percorso B: types first
Adatto a: lavoro guidato dal frontend con librerie come zod / valibot, dove "lo schema è il tipo".
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',
});
Qui zod ricopre entrambi i ruoli — "definizione del tipo" e "validazione a runtime" — e JSON Schema è solo un artefatto di esportazione. Questo è lo sforzo complessivo minore, ma tieni a mente due cose:
z.infere lo JSON Schema generato non si mappano esattamente perdefault,transformerefine;- Lo schema generato può contenere
$refedefinitions, quindi controlla prima di pubblicare se corrisponde alla specifica di destinazione.
4. Anche la direzione inversa necessita di controllo
Qualunque percorso scegli, aggiungi un controllo in CI: gli artefatti generati devono corrispondere ai file nel repository.
# 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
Senza questo passo, tre mesi dopo nessuno sa se api.d.ts sia aggiornato.
5. Quattro trappole ad alta frequenza
5.1 optional e nullable non sono la stessa cosa
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 termini di schema:
{ "properties": { "name": { "type": "string" } } } // optional
{ "required": ["name"], "properties": { "name": { "type": ["string", "null"] } } } // nullable
I generatori gestiscono i due casi in modo completamente diverso. Il contratto deve dichiarare quale intendi; altrimenti il data.name?.length del frontend andrà in crash su null.
5.2 Enum in evoluzione
type Status = 'pending' | 'paid' | 'refunded';
Se il backend aggiunge 'cancelled' e i tipi del frontend non vengono aggiornati, il codice cade nel ramo default.
I campi simili a enum dovrebbero gestire esplicitamente i valori sconosciuti:
function normalizeStatus(raw: string): Status | 'unknown' {
return raw === 'pending' || raw === 'paid' || raw === 'refunded' ? raw : 'unknown';
}
5.3 Date e denaro non possono semplicemente usare string
Nello schema, format: "date-time" è solo un'annotazione; in TypeScript è solo string.
Quando ti serve una garanzia più forte, usa un "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 Interi grandi
type: "integer" non è equivalente a number una volta che i valori superano 2^53. Scrivilo nel contratto come:
{ "type": "string", "pattern": "^[0-9]+$", "description": "Order number; exceeds 2^53, must be a string" }
6. Un processo di rollout suggerito
- Definisci il contratto: inizia scrivendo la struttura richiesta/risposta come schema (o zod);
- Genera i tipi: genera
.d.tsin CI e committalo nel repository così le differenze sono facili da revisionare; - Genera i validatori: compila lo stesso schema in una funzione di validazione Ajv, e chiamala a ogni punto di ingresso del server;
- Genera la documentazione: emetti Markdown o OpenAPI dallo schema così la documentazione non invecchia mai a mano;
- Regressione: esegui i fixture dell'API sia attraverso il "controllo dei tipi" sia attraverso la "validazione dello schema"; solo passare entrambi conta.
7. Lista di controllo
- [ ] Esiste esattamente un'unica fonte di verità; l'altra copia è generata da uno script;
- [ ] Lo script di generazione gira in CI, e qualsiasi differenza dell'artefatto fa fallire la build;
- [ ]
optionalenullablesono distinti esplicitamente nel contratto; - [ ] I campi enum hanno un ripiego per i valori sconosciuti;
- [ ] Interi grandi, denaro e tempo hanno convenzioni esplicite sia nello schema sia nei tipi;
- [ ] I file di tipo generati portano un'intestazione "non modificare a mano".
FAQ
Come decido tra schema-first e types-first?
Scegli schema-first quando il backend possiede il contratto, necessita uso tra linguaggi, o pubblica il contratto pubblicamente, e genera i tipi con json-schema-to-typescript. Scegli una libreria in stile zod "lo schema è il tipo" quando il frontend guida e vuole tipi e validazione definiti una volta, poi esporta JSON Schema. Entrambi i percorsi necessitano di un passo CI che confronti gli artefatti generati con i file del repository.
Perché le definizioni di tipi e schema scritte a mano vanno sempre alla deriva?
I tipi vengono cancellati in fase di compilazione mentre gli schema hanno effetto solo a runtime, e i due vengono modificati da persone diverse in momenti diversi. Senza generazione e confronto, una copia viene aggiornata mentre l'altra resta ferma, il che produce "compila, poi restituisce 500 a runtime".
Devo committare i file di tipo generati nel repository?
Sì. Committarli permette ai revisori di vedere le modifiche al contratto, e la CI può confrontare l'output di generazione fresco con i file del repository e fallire su qualsiasi differenza. Lascia gli artefatti solo in una cartella locale o nella cache di build e nessuno saprà tre mesi dopo se sono aggiornati.