JSON Toolkit

JSON в TypeScript: интерфейсы, type guards и пределы генерации

Главное: Генерация интерфейса по примеру JSON — половина работы: необязательные поля, null, объединения, даты и большие целые требуют ручной правки, а типы не дают валидации во время выполнения.

Средний~9 мин чтенияОпубликовано 2026-10-31Обновлено 2026-11-04
Обложка руководства по интерфейсам TypeScript и type guards из JSON

Коротко: сгенерируйте интерфейс, чтобы не печатать руками, затем заново проверьте необязательность и null, оставьте даты и большие целые строками и добавьте валидацию во время выполнения на границе; если считать сгенерированные типы контрактом, вы не получите никакой валидации.

Генератор видит только тот один пример, который вы в него вставили. Могут ли поля отсутствовать, могут ли значения быть null и в каком формате приходят даты — всё это догадки, и исправлять неверные догадки вам.

1. Что на самом деле делает генератор

Вставьте пример JSON в JSON в TypeScript, и полученный интерфейс будет кратким описанием формы: генератор подсчитывает виденные ключи и типы значений, а затем выбирает самое узкое доступное представление.

{
  "id": 1,
  "email": "ada@example.com",
  "profile": { "nickname": "Ada", "age": 36 },
  "tags": ["admin", "beta"],
  "createdAt": "2026-10-31T08:00:00Z"
}

Вывод выглядит примерно так:

interface User {
  id: number;
  email: string;
  profile: { nickname: string; age: number };
  tags: string[];
  createdAt: string;
}

Это компилируется, но описывает идеальное значение, а не реальный контракт эндпоинта. Генератор не знает, может ли email отсутствовать, может ли tags быть пустым и всегда ли createdAt несёт часовой пояс. Он даже не знает, что в других записях id может быть строкой.

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

2. Необязательные поля и null: ? — это не | null

Именно эту часть сгенерированного вывода копируют чаще всего, и именно она чаще всего ломается.

Форма в TypeScript Значение Соответствующий JSON
email: string Всегда присутствует, всегда строка {"email":"a@b.c"}
email?: string Ключ может отсутствовать; если присутствует, не может быть null {} или {"email":"a@b.c"}
email: string | null Ключ всегда присутствует, значение может быть null {"email":null}
email?: string | null Ключ может отсутствовать, и значение может быть null Все три случая

Генератор видел один пример, поэтому обычно выдаёт первую форму. Если API на самом деле может вернуть null, тип лжёт: всё компилируется, а потом email.toLowerCase() падает во время выполнения.

Исправить можно двумя способами:

  1. Соберите несколько реальных примеров, покрывающих и «ключ отсутствует», и «значение null», и дайте генератору вывести типы заново.
  2. После генерации вручную поменяйте поля на ?: T | null и реально обработайте обе ветки в коде.

Не идите по короткому пути «пометить всё как ?: string». Необязательность означает «ключа может не быть», а не «значение может быть пустым». undefined и null также ведут себя по-разному в JSON.stringify: первое опускается, второе сохраняется как null.

3. Неоднородные элементы массивов и объединения типов

Массивы — самая простая структура, в которой легко ошибиться. Если tags в трёх записях выглядит как ["admin"], [] и ["beta", 3], генератор может выдать string[], (string | number)[] или unknown[] в зависимости от того, какие примеры он видел.

Человек должен решить:

  • Типы элементов действительно смешаны: напишите (string | number)[] и сужайте тип в месте использования.
  • Грязная только одна запись: исправьте данные. Не прячьте проблему за any[].
  • Пустые массивы: генератор не может вывести тип элемента и откатывается к never[] или any[], поэтому его нужно заполнить вручную.
  • Кортежи фиксированной длины: используйте [string, string], только если бизнес действительно гарантирует длину; иначе добавление элемента позже станет ошибкой компиляции.

Массивы объектов тоже требуют внимания, когда ключи неоднородны:

interface OrderItem {
  sku: string;
  quantity: number;
  discount?: number; // some records do not carry this field
}

Необязательность ставьте на тип элемента, а не на массив. items?: OrderItem[] означает, что может отсутствовать сам массив, — это совершенно другое утверждение.

4. Даты, большие целые, перечисления: типы, которых в JSON нет

В JSON есть только строки и числа; бизнес-смысл живёт в соглашениях. Генератор выводит типы из литералов, поэтому исправляйте вручную:

Литерал JSON Обычный вывод генератора Как должно быть
"2026-10-31T08:00:00Z" string Оставляйте string в транспортном слое и преобразуйте в месте использования; никогда не объявляйте Date напрямую
1717075200000 number Используйте number для метки времени в миллисекундах и документируйте единицу
"1234567890123456789" string Оставляйте string, чтобы number не потерял точность
1 и "1" вперемешку number | string Нормализуйте к одной форме; не протаскивайте объединение в бизнес-код
"admin" string Литеральное объединение 'admin' | 'editor' или общая константа перечисления
true и "true" вперемешку boolean | string Исправьте источник данных, а не генерируйте такой тип

Самое важное правило: даты в JSON всегда строки. Запись createdAt: Date — распространённое самообманывание: JSON.parse никогда не создаёт Date, значение во время выполнения остаётся строкой, и .getTime() падает. Оставляйте транспортный тип string, явно вызывайте new Date(value) на границе и проверяйте Number.isNaN.

С большими целыми так же. Когда id превышает 2^53, JSON.parse уже потерял точность, а генерация number лишь закрепляет баг в типе. Бэкенд возвращает строку, фронтенд хранит строку — это единственный надёжный вариант.

5. Сгенерированные типы не дают валидации во время выполнения

Типы TypeScript стираются при компиляции. Как бы точен ни был интерфейс, он не остановит ответ {"id": "x"} — он ограничивает ваш собственный код, а не байты, приходящие по сети.

/**
 * Check whether a runtime value is a user object.
 * @param value The runtime value to validate
 * @returns Whether it satisfies the minimum shape of User
 */
function isUser(value: unknown): value is User {
  if (typeof value !== 'object' || value === null) {
    return false;
  }
  const candidate = value as Record<string, unknown>;
  return typeof candidate.id === 'number' && typeof candidate.email === 'string';
}

Type guard покрывает только те поля, которые вы вписали в проверку. По мере роста числа полей рукописные guard'ы становятся вторым определением, которое нужно поддерживать и которое дрейфует от интерфейса.

Практический компромисс выглядит так:

Подход Валидация во время выполнения Источник типов Подходит для
Только интерфейс Нет Написан вручную или сгенерирован Доверенные внутренние данные, ограничения на этапе компиляции
Интерфейс плюс рукописный guard Есть Два определения, которые дрейфуют Мало полей, чёткая граница
zod / valibot Есть Типы выводятся из схемы Внешние API, формы, конфигурация
JSON Schema плюс валидатор Есть Типы генерируются из схемы Межъязыковые контракты, документация

Если данные приходят извне, сделайте схему единственным источником истины и выводите типы через z.infer или json-schema-to-ts. Тогда «валидация прошла» и «тип верен» — один и тот же факт, и второго определения, которое дрейфует, нет. О полном подходе к двусторонней синхронизации читайте в статье Синхронизация Schema и типов TypeScript.

6. interface или type: не спорьте о сгенерированном выводе

Генераторы обычно выдают interface, но в большинстве ситуаций эти два взаимозаменяемы. Важны три возможности:

  • Слияние объявлений: interface можно объявить несколько раз, и они сольются автоматически; type так не умеет. Это помогает при дополнении сторонних типов, но бизнес-модели должны этого избегать — слияние позволяет «лишним полям» проскочить мимо компилятора незамеченными.
  • Объединения и отображаемые типы: type напрямую выражает A | B, Partial<T> и шаблонные литеральные типы; interface — нет.
  • Рекурсия: поддерживают оба, но сложные рекурсивные формы естественнее читаются как type.

Более полезное командное правило: interface для форм объектов, type для объединений, служебных типов и типов функций. Сгенерированный вывод оставляйте по умолчанию, а время ревью тратьте на необязательность и валидацию во время выполнения.

7. Инструментарий: три способа генерации

Подход Вход Сильная сторона Ограничение
JSON в TypeScript на сайте Пример JSON Работает сразу, обрабатывается локально Только по текущему примеру; нужно ручное сужение
quicktype JSON / JSON Schema Вывод для многих языков, интеграция в CLI Консервативный вывод, объединения могут быть слишком широкими
json-schema-to-typescript JSON Schema Управляется контрактом и воспроизводимо Сначала нужна заслуживающая доверия схема

Типичный вызов quicktype:

npx quicktype -s json -o user.ts user.json --just-types

--just-types выдаёт только типы без кода преобразования во время выполнения, что подходит проектам, где уже есть слой разбора. Если схема написана вручную или создана бэкендом, лучше подходит json-schema-to-typescript, потому что он учитывает required, enum и additionalProperties — ограничения, которых вывод по примеру просто не видит.

Генерация схем имеет свои ловушки, например пометку всех полей обязательными или молчаливое превращение null в необязательность; см. частые ловушки генератора JSON Schema.

8. Чек-лист перед коммитом сгенерированных типов

Пройдите пять пунктов, прежде чем коммитить вывод:

  1. Нормализуйте имена полей: не позволяйте userId и user_id сосуществовать; сопоставляйте один раз в слое преобразования.
  2. Заново проверьте необязательность: сверьтесь с реальными ответами и найдите поля, которые могут отсутствовать или быть null.
  3. Оставляйте даты и идентификаторы строками: документируйте единицу и формат в типе и разбирайте явно в месте использования.
  4. Сокращайте any и unknown: везде, где генератор выдал any, добавьте тип или сохраните unknown и добавьте guard.
  5. Добавьте валидацию во время выполнения: проверяйте внешние данные хотя бы один раз; не ожидайте, что интерфейс перехватит плохие данные.

Если проект использует несколько библиотек JSON (JSON.parse, superjson, lossless-json), их поведение с большими целыми и датами различается, поэтому уточняйте его при выборе. См. Выбор библиотеки разбора JSON.

Почему генератор помечает все необязательные поля обязательными?

Он видел один пример, поэтому каждый появившийся ключ считается всегда присутствующим. Предоставьте несколько примеров, покрывающих разные случаи, или генерируйте из JSON Schema — массив required там и есть авторитетный источник.

Почему объявление Date в интерфейсе падает во время выполнения?

JSON.parse создаёт только строки, числа, логические значения, null, объекты и массивы; он никогда не создаёт Date. Объявление Date — обещание на этапе компиляции, тогда как значение во время выполнения остаётся строкой. Оставляйте string в транспортном слое и преобразуйте на границе.

Можно ли использовать сгенерированные типы как валидацию?

Нет. Типы TypeScript стираются после компиляции и ничего не накладывают на данные во время выполнения. Проверяйте внешние данные через zod, valibot, ajv или рукописный type guard.

Каким типом объявлять большой целочисленный идентификатор — number или string?

Если он может превысить 2^53, то обязательно string. JSON.parse теряет точность при разборе, а типизация как number лишь записывает баг в тип, где его уже не исправить.