Двостороння синхронізація між Schema і типами TypeScript
Головне: Типи й схеми кожні покривають один відрізок: одні під час компіляції, інша під час виконання. Питання не в тому, що обрати, а в тому, як автоматично тримати їх синхронізованими.

Коротко: спершу оберіть одне джерело істини — schema-first, коли контрактом володіє бекенд, types-first у стилі zod, коли фронтенд — потім згенеруйте іншу копію й порівнюйте її в CI; вручну пишіть лише семантику, яку генератор не може виразити, як-от брендовані типи.
Якщо ви пишете обидва визначення вручну, вони розійдуться. Ціна розходження — «компілюється, а потім повертає 500 під час виконання».
1. Чому тримати лише одну копію не працює
| Можливість | Типи TypeScript | JSON Schema |
|---|---|---|
| Перевірка під час компіляції | ✓ | ✗ |
| Перевірка під час виконання | ✗ (типи стираються) | ✓ |
| Міжмовне використання | ✗ | ✓ (Java/Go/Python усі можуть її читати) |
| Доповнення й рефакторинг в IDE | ✓ | Так собі |
| Генерація документації | Так собі | ✓ (екосистема OpenAPI) |
Вхід API надходить із мережі й завжди є unknown, тож перевірку під час виконання не можна пропустити; а пишучи бізнес-код, ви все одно потребуєте доповнення типів.
Розсудливий підхід — обрати одне джерелом істини й генерувати інше.
2. Шлях A: спершу схема
Підходить для: роботи, керованої бекендом, міжмовного використання й публічних контрактів.
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');
}
Кілька конвенцій у виводі варті уваги:
required: ["id"]→id: string; ключі, не перелічені вrequired→id?: string;"type": ["string", "null"]→string | null;enum→ обʼєднання літеральних типів, значно точніше заstring;additionalProperties: falseвизначає, чи генерується індексна сигнатура.
Є також варіант без збірки, json-schema-to-ts, що виводить безпосередньо на рівні типів:
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 }
Перевага — без кроку генерації й типи завжди синхронні зі схемою; недолік — схему треба писати як as const, а складні сценарії $ref компілюються повільно.
3. Шлях B: спершу типи
Підходить для: роботи, керованої фронтендом, із бібліотеками на кшталт zod / valibot, де «схема і є тип».
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>;
/** Контракт, опублікований для інших мов */
export const userJsonSchema = zodToJsonSchema(userSchema, {
target: 'jsonSchema2020-12',
$refStrategy: 'none',
});
Тут zod грає обидві ролі — «визначення типу» й «перевірка під час виконання» — а JSON Schema є лише артефактом експорту. Це найменше зусиль загалом, але майте на увазі дві речі:
z.inferі згенерована JSON Schema не відображаються точно дляdefault,transformіrefine;- Згенерована схема може містити
$refіdefinitions, тож перевірте перед публікацією, чи вона відповідає цільовій специфікації.
4. Зворотний напрямок теж потребує перевірки
Який би шлях ви не обрали, додайте перевірку в CI: згенеровані артефакти мають збігатися з файлами в репозиторії.
# Згенерувати в тимчасовий каталог і порівняти; CI зазнає збою на будь-якій відмінності
node scripts/generate-types.mjs --out .tmp/types.d.ts
diff -u src/types/api.d.ts .tmp/types.d.ts
Без цього кроку через три місяці ніхто не знає, чи api.d.ts актуальний.
5. Чотири часті пастки
5.1 optional і nullable — не те саме
type A = { name?: string }; // ключ може бути відсутнім
type B = { name: string | null }; // ключ мусить існувати, але значення може бути null
У термінах схеми:
{ "properties": { "name": { "type": "string" } } } // необовʼязкове
{ "required": ["name"], "properties": { "name": { "type": ["string", "null"] } } } // може бути null
Генератори обробляють ці два випадки цілком по-різному. Контракт має заявити, який із них ви маєте на увазі; інакше data.name?.length на фронтенді зазнає краху на null.
5.2 Еволюція переліків
type Status = 'pending' | 'paid' | 'refunded';
Якщо бекенд додає 'cancelled', а типи фронтенду не оновлено, код потрапляє в гілку default.
Поля у стилі переліку мають явно обробляти невідомі значення:
function normalizeStatus(raw: string): Status | 'unknown' {
return raw === 'pending' || raw === 'paid' || raw === 'refunded' ? raw : 'unknown';
}
5.3 Дати й гроші не можуть просто використовувати string
У схемі format: "date-time" — лише анотація; у TypeScript це просто string.
Коли потрібна сильніша гарантія, використовуйте «брендований тип»:
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 Великі цілі числа
type: "integer" не еквівалентний number, щойно значення перевищують 2^53. Пишіть у контракті так:
{ "type": "string", "pattern": "^[0-9]+$", "description": "Order number; exceeds 2^53, must be a string" }
6. Пропонований процес розгортання
- Визначте контракт: почніть із запису структури запиту/відповіді як схеми (або zod);
- Згенеруйте типи: генеруйте
.d.tsу CI й комітьте його в репозиторій, щоб відмінності було легко переглядати; - Згенеруйте валідатори: скомпілюйте ту саму схему у функцію перевірки Ajv і викликайте її на кожній точці входу сервера;
- Згенеруйте документацію: виводьте Markdown або OpenAPI зі схеми, щоб документація ніколи не застарівала вручну;
- Регресія: проженіть тестові дані API через обидві «перевірку типів» і «перевірку схеми»; зараховується лише проходження обох.
7. Чек-лист
- [ ] Існує рівно одне джерело істини; іншу копію генерує скрипт;
- [ ] Скрипт генерації запускається в CI, і будь-яка відмінність артефакту провалює збірку;
- [ ]
optionalіnullableявно розрізнені в контракті; - [ ] Поля-переліки мають запасний варіант для невідомих значень;
- [ ] Великі цілі числа, гроші й час мають явні конвенції і в схемі, і в типах;
- [ ] Згенеровані файли типів несуть заголовок «не редагувати вручну».
Часті питання
Як вирішити між schema-first і types-first?
Обирайте schema-first, коли контрактом володіє бекенд, потрібне міжмовне використання або контракт публікується публічно, і генеруйте типи через json-schema-to-typescript. Обирайте бібліотеку в стилі zod «схема і є тип», коли фронтенд веде й хоче визначити типи й перевірку один раз, а потім експортувати JSON Schema. Обидва шляхи потребують кроку CI, що порівнює згенеровані артефакти з файлами репозиторію.
Чому рукописні визначення типів і схем завжди розходяться?
Типи стираються під час компіляції, тоді як схеми діють лише під час виконання, і їх редагують різні люди в різний час. Без генерації й порівняння одну копію оновлюють, а інша стоїть на місці, що дає «компілюється, а потім повертає 500 під час виконання».
Чи комітити згенеровані файли типів у репозиторій?
Так. Коміт дає рецензентам бачити зміни контракту, а CI може порівняти свіжий вивід генерації з файлами репозиторію й зазнати збою на будь-якій відмінності. Залиште артефакти лише в локальній теці чи кеші збірки — і через три місяці ніхто не знатиме, чи вони актуальні.