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