JSON Toolkit

Двостороння синхронізація між Schema і типами TypeScript

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

Середній~11 хв читанняОпубліковано 2026-10-30Оновлено 2026-10-30
Обкладинка статті «Двостороння синхронізація між 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. Пропонований процес розгортання

  1. Визначте контракт: почніть із запису структури запиту/відповіді як схеми (або zod);
  2. Згенеруйте типи: генеруйте .d.ts у CI й комітьте його в репозиторій, щоб відмінності було легко переглядати;
  3. Згенеруйте валідатори: скомпілюйте ту саму схему у функцію перевірки Ajv і викликайте її на кожній точці входу сервера;
  4. Згенеруйте документацію: виводьте Markdown або OpenAPI зі схеми, щоб документація ніколи не застарівала вручну;
  5. Регресія: проженіть тестові дані API через обидві «перевірку типів» і «перевірку схеми»; зараховується лише проходження обох.

7. Чек-лист

  • [ ] Існує рівно одне джерело істини; іншу копію генерує скрипт;
  • [ ] Скрипт генерації запускається в CI, і будь-яка відмінність артефакту провалює збірку;
  • [ ] optional і nullable явно розрізнені в контракті;
  • [ ] Поля-переліки мають запасний варіант для невідомих значень;
  • [ ] Великі цілі числа, гроші й час мають явні конвенції і в схемі, і в типах;
  • [ ] Згенеровані файли типів несуть заголовок «не редагувати вручну».

Часті питання

Як вирішити між schema-first і types-first?

Обирайте schema-first, коли контрактом володіє бекенд, потрібне міжмовне використання або контракт публікується публічно, і генеруйте типи через json-schema-to-typescript. Обирайте бібліотеку в стилі zod «схема і є тип», коли фронтенд веде й хоче визначити типи й перевірку один раз, а потім експортувати JSON Schema. Обидва шляхи потребують кроку CI, що порівнює згенеровані артефакти з файлами репозиторію.

Чому рукописні визначення типів і схем завжди розходяться?

Типи стираються під час компіляції, тоді як схеми діють лише під час виконання, і їх редагують різні люди в різний час. Без генерації й порівняння одну копію оновлюють, а інша стоїть на місці, що дає «компілюється, а потім повертає 500 під час виконання».

Чи комітити згенеровані файли типів у репозиторій?

Так. Коміт дає рецензентам бачити зміни контракту, а CI може порівняти свіжий вивід генерації з файлами репозиторію й зазнати збою на будь-якій відмінності. Залиште артефакти лише в локальній теці чи кеші збірки — і через три місяці ніхто не знатиме, чи вони актуальні.