JSON Toolkit

Dwukierunkowa synchronizacja schematu i typów TypeScript

Podsumowanie: Typy i schematy pokrywają po jednym segmencie: jeden w czasie kompilacji, drugi w czasie wykonania. Pytanie nie brzmi, który wybrać, ale jak automatycznie utrzymać je w synchronizacji.

Średniozaawansowany~11 min czytaniaOpublikowano 2026-10-30Zaktualizowano 2026-10-30
Okładka artykułu o dwukierunkowej synchronizacji schematu i typów TypeScript

Najważniejsze: najpierw wybierz jedno źródło prawdy — schema-first, gdy backend jest właścicielem kontraktu, types-first w stylu zod, gdy frontend — potem generuj drugą kopię i diffuj ją w CI; ręcznie pisz tylko semantykę, której generator nie potrafi wyrazić, taką jak typy branded.

Jeśli ręcznie piszesz obie definicje, będą dryfować. Ceną dryfu jest „kompiluje się, a potem zwraca 500 w czasie wykonania”.

1. Dlaczego trzymanie tylko jednej kopii nie działa

Możliwość Typy TypeScript JSON Schema
Sprawdzanie w czasie kompilacji ✓ ✗
Walidacja w czasie wykonania ✗ (typy są wymazywane) ✓
Użycie między językami ✗ ✓ (Java/Go/Python potrafią je czytać)
Uzupełnianie i refaktoryzacja w IDE ✓ Tak sobie
Generowanie dokumentacji Tak sobie ✓ (ekosystem OpenAPI)

Wejście API przychodzi z sieci i zawsze jest unknown, więc walidacji w czasie wykonania nie da się pominąć; a pisząc kod biznesowy, nadal potrzebujesz uzupełniania typów. Sensowne podejście to wybrać jedno jako źródło prawdy i generować drugie.

2. Ścieżka A: schema first

Pasuje do: pracy napędzanej przez backend, użycia między językami i publicznych kontraktów.

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');
}

Kilka konwencji w wyniku zasługuje na uwagę:

  • required: ["id"] → id: string; klucze niewymienione w required → id?: string;
  • "type": ["string", "null"] → string | null;
  • enum → unia typów literałowych, znacznie precyzyjniejsza niż string;
  • additionalProperties: false decyduje, czy generowana jest sygnatura indeksu.

Jest też opcja zero-build, json-schema-to-ts, która wnioskuje bezpośrednio na poziomie typów:

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 }

Zaletą jest brak etapu generowania i typy zawsze zsynchronizowane ze schematem; wadą jest to, że schemat trzeba pisać jako as const, a złożone scenariusze $ref kompilują się wolno.

3. Ścieżka B: types first

Pasuje do: pracy napędzanej przez frontend z bibliotekami takimi jak zod / valibot, gdzie „schemat jest typem”.

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',
});

Tutaj zod pełni obie role — „definicji typu” i „walidacji w czasie wykonania” — a JSON Schema jest jedynie artefaktem eksportu. To najmniejszy wysiłek ogółem, ale pamiętaj o dwóch rzeczach:

  • z.infer i wygenerowany JSON Schema nie mapują się dokładnie dla default, transform i refine;
  • Wygenerowany schemat może zawierać $ref i definitions, więc sprawdź przed publikacją, czy pasuje do docelowej specyfikacji.

4. Kierunek odwrotny też wymaga sprawdzenia

Niezależnie od wybranej ścieżki dodaj kontrolę CI: wygenerowane artefakty muszą zgadzać się z plikami w repozytorium.

# 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

Bez tego etapu trzy miesiące później nikt nie wie, czy api.d.ts jest aktualny.

5. Cztery częste pułapki

5.1 optional i nullable to nie to samo

type A = { name?: string };        // the key may be absent
type B = { name: string | null };  // the key must exist, but the value may be null

W kategoriach schematu:

{ "properties": { "name": { "type": "string" } } }                        // optional
{ "required": ["name"], "properties": { "name": { "type": ["string", "null"] } } } // nullable

Generatory obsługują te dwa przypadki zupełnie inaczej. Kontrakt musi mówić, który masz na myśli; inaczej data.name?.length we frontendzie wysypie się na null.

5.2 Ewolucja wyliczeń

type Status = 'pending' | 'paid' | 'refunded';

Jeśli backend doda 'cancelled', a typy frontendu nie zostaną zaktualizowane, kod wpada do gałęzi default. Pola wyliczeniowe powinny jawnie obsługiwać nieznane wartości:

function normalizeStatus(raw: string): Status | 'unknown' {
  return raw === 'pending' || raw === 'paid' || raw === 'refunded' ? raw : 'unknown';
}

5.3 Daty i pieniądze nie mogą po prostu używać string

W schemacie format: "date-time" to tylko adnotacja; w TypeScript to po prostu string. Gdy potrzebujesz silniejszej gwarancji, użyj „typ branded”:

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 Duże liczby całkowite

type: "integer" nie jest równoważne number, gdy wartości przekraczają 2^53. Zapisz w kontrakcie:

{ "type": "string", "pattern": "^[0-9]+$", "description": "Order number; exceeds 2^53, must be a string" }

6. Sugerowany proces wdrożenia

  1. Zdefiniuj kontrakt: zacznij od struktury żądania/odpowiedzi jako schematu (albo zod);
  2. Generuj typy: generuj .d.ts w CI i commituj do repozytorium, aby różnice były łatwe do przeglądu;
  3. Generuj walidatory: skompiluj ten sam schemat w funkcję walidacji Ajv i wywołuj ją w każdym punkcie wejścia serwera;
  4. Generuj dokumentację: emituj Markdown lub OpenAPI ze schematu, aby dokumentacja nigdy nie starzała się ręcznie;
  5. Regresja: przepuść dane testowe API przez „sprawdzanie typów” i „walidację schematem”; liczy się tylko przejście obu.

7. Lista kontrolna

  • [ ] Istnieje dokładnie jedno źródło prawdy; druga kopia jest generowana przez skrypt;
  • [ ] Skrypt generowania działa w CI, a każda różnica artefaktu psuje budowanie;
  • [ ] optional i nullable są jawnie rozróżnione w kontrakcie;
  • [ ] Pola wyliczeniowe mają fallback dla nieznanych wartości;
  • [ ] Duże liczby całkowite, pieniądze i czas mają jawne konwencje zarówno w schemacie, jak i w typach;
  • [ ] Generowane pliki typów niosą nagłówek „nie edytuj ręcznie”.

FAQ

Jak wybrać między schema-first a types-first?

Wybierz schema-first, gdy backend jest właścicielem kontraktu, potrzebne jest użycie między językami albo kontrakt jest publikowany publicznie, i generuj typy przez json-schema-to-typescript. Wybierz bibliotekę typu zod, w której „schemat jest typem”, gdy frontend wiedzie i chce zdefiniować typy oraz walidację raz, a potem wyeksportować JSON Schema. Każda ścieżka wymaga etapu CI porównującego wygenerowane artefakty z plikami repozytorium.

Dlaczego ręcznie pisane definicje typów i schematów zawsze dryfują?

Typy są wymazywane w czasie kompilacji, a schematy działają tylko w czasie wykonania, i obie kopie są edytowane przez różne osoby w różnym czasie. Bez generowania i porównywania jedna kopia jest aktualizowana, a druga stoi w miejscu, co produkuje „kompiluje się, a potem zwraca 500 w czasie wykonania”.

Czy commitować wygenerowane pliki typów do repozytorium?

Tak. Commitowanie pozwala przeglądającym zobaczyć zmiany kontraktu, a CI może porównać świeży wynik generowania z plikami repozytorium i zawieść przy każdej różnicy. Zostawienie artefaktów tylko w lokalnym folderze lub cache’u budowania sprawia, że trzy miesiące później nikt nie wie, czy są aktualne.