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.

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 wrequired→id?: string;"type": ["string", "null"]→string | null;enum→ unia typów literałowych, znacznie precyzyjniejsza niżstring;additionalProperties: falsedecyduje, 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.inferi wygenerowany JSON Schema nie mapują się dokładnie dladefault,transformirefine;- Wygenerowany schemat może zawierać
$refidefinitions, 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
- Zdefiniuj kontrakt: zacznij od struktury żądania/odpowiedzi jako schematu (albo zod);
- Generuj typy: generuj
.d.tsw CI i commituj do repozytorium, aby różnice były łatwe do przeglądu; - Generuj walidatory: skompiluj ten sam schemat w funkcję walidacji Ajv i wywołuj ją w każdym punkcie wejścia serwera;
- Generuj dokumentację: emituj Markdown lub OpenAPI ze schematu, aby dokumentacja nigdy nie starzała się ręcznie;
- 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;
- [ ]
optionalinullablesą 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.