JSON Toolkit

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.

Intermedio~11 min di letturaPubblicato il 2026-10-30Aggiornato il 2026-10-30
Copertina di Sincronizzazione bidirezionale tra Schema e tipi TypeScript

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 in required → id?: string;
  • "type": ["string", "null"] → string | null;
  • enum → un'unione di tipi letterali, molto più precisa di string;
  • additionalProperties: false determina 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.infer e lo JSON Schema generato non si mappano esattamente per default, transform e refine;
  • Lo schema generato può contenere $ref e definitions, 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

  1. Definisci il contratto: inizia scrivendo la struttura richiesta/risposta come schema (o zod);
  2. Genera i tipi: genera .d.ts in CI e committalo nel repository così le differenze sono facili da revisionare;
  3. Genera i validatori: compila lo stesso schema in una funzione di validazione Ajv, e chiamala a ogni punto di ingresso del server;
  4. Genera la documentazione: emetti Markdown o OpenAPI dallo schema così la documentazione non invecchia mai a mano;
  5. 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;
  • [ ] optional e nullable sono 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.