JSON Toolkit

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

Главное: Типы и схемы покрывают по одному отрезку: один во время компиляции, один во время выполнения. Вопрос не в том, что выбрать, а в том, как автоматически держать оба в синхронизации.

Средний~11 мин чтенияОпубликовано 2026-10-30Обновлено 2026-10-30
Обложка двусторонней синхронизации Schema и типов TypeScript

Главное: сначала выберите один источник истины — schema-first, когда контрактом владеет бэкенд, или style-zod types-first, когда им владеет фронтенд — затем генерируйте другую копию и сравнивайте её в CI; вручную пишите только семантику, которую генератор не может выразить, например branded-типы.

Если вы вручную пишете оба определения, они будут дрейфовать. Цена дрейфа — «компилируется, затем возвращает 500 во время выполнения».

1. Почему хранение только одной копии не работает

Возможность Типы TypeScript JSON Schema
Проверка во время компиляции ✓ ✗
Валидация во время выполнения ✗ (типы стираются) ✓
Кросс-языковое использование ✗ ✓ (Java/Go/Python могут все читать её)
Автодополнение и рефакторинг в IDE ✓ Так себе
Генерация документации Так себе ✓ (экосистема OpenAPI)

Вход API приходит из сети и всегда unknown, поэтому валидацию во время выполнения нельзя пропустить; и при написании бизнес-кода вам всё ещё нужно автодополнение типов. Разумный подход — выбрать один источником истины и генерировать другой.

2. Путь A: schema first

Подходит для: работы, движимой бэкендом, кросс-языкового использования и публичных контрактов.

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 определяет, генерируется ли индексная сигнатура.

Есть также zero-build вариант, 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: types first

Подходит для: работы, движимой фронтендом, с библиотеками вроде 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"] } } } // nullable

Генераторы обрабатывают эти два случая совершенно по-разному. Контракт должен указывать, что вы имеете в виду; иначе 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. Когда нужна более сильная гарантия, используйте «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 Большие целые

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 может сравнить свежий вывод генерации с файлами репозитория и упасть при любом различии. Оставьте артефакты только в локальной папке или кеше сборки, и никто не узнает три месяца спустя, актуальны ли они.