JSON Toolkit

JSON Schema: от основ до продакшена

Главное: Ключевых слов много, но ежедневно используются лишь дюжина. Правильно освойте type/properties/required/$ref, хорошо обработайте сообщения об ошибках — и покроете восемьдесят процентов реальных случаев.

Средний~14 мин чтенияОпубликовано 2026-10-30Обновлено 2026-10-30
Обложка JSON Schema: от основ до продакшена

Главное: сначала закрепите $schema на 2020-12, затем извлеките общие структуры через $defs + $ref; используйте oneOf только когда должна совпасть ровно одна ветка, и предпочитайте дискриминаторы или anyOf везде остальном.

Схема — не приложение к документации; это актив, который можно исполнять, тестировать и генерировать.

1. Сначала проясните версию

Версия Значение $schema Статус Примечания
draft-04 http://json-schema.org/draft-04/schema# Устаревшая Наследуемые проекты и некоторые инструменты OpenAPI 2.0
draft-07 http://json-schema.org/draft-07/schema# Широко используется Де-факто основа OpenAPI 3.0
2019-09 https://json-schema.org/draft/2019-09/schema Доступна Введены $defs и словарь аннотаций
2020-12 https://json-schema.org/draft/2020-12/schema Сейчас рекомендуется Введён prefixItems, изменена семантика items

Используйте 2020-12 для новых проектов. Учтите, что в 2020-12 items ограничивает только «остальные элементы» — объявления в стиле кортежей переехали в prefixItems. Это самая частая ловушка при миграции с draft-07.

2. Минимальная рабочая схема

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://bbdrug.com/schemas/user.json",
  "type": "object",
  "required": ["id", "name"],
  "properties": {
    "id": { "type": "string", "minLength": 1 },
    "name": { "type": "string", "minLength": 1, "maxLength": 64 },
    "age": { "type": "integer", "minimum": 0, "maximum": 150 },
    "tags": { "type": "array", "items": { "type": "string" }, "uniqueItems": true }
  },
  "additionalProperties": false
}

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

3. Карта ключевых слов

3.1 Общие

Ключевое слово Назначение На что смотреть
type Тип; может быть массивом "type": ["string", "null"] означает nullable
enum Перечисленные значения Значения должны быть уникальными
const Ограничение одним значением Хорошо для фиксированных значений вроде номера версии
$ref Ссылается на другую схему Ссылается на всю схему и не слияет с соседними ключевыми словами на ссылающемся объекте

3.2 Числа

multipleOf, maximum, exclusiveMaximum, minimum, exclusiveMinimum. Учтите, что в draft-04 exclusiveMaximum был булевым (использовался вместе с maximum); только с draft-07 он стал отдельным числовым ключевым словом — что заставляет многие «схемы, скопированные из старых документов» падать под строгим режимом.

3.3 Строки

minLength, maxLength, pattern (регулярное выражение ECMA-262) и format (аннотация).

minLength считает кодовые точки Unicode, а не единицы UTF-16 и не байты. Валидация через string.length в JavaScript будет с ним расходиться.

3.4 Массивы

Ключевое слово Назначение
prefixItems Ограничение в стиле кортежа 2020-12, сопоставляющее элементы по одному по индексу
items Однородное ограничение на остальные элементы (2020-12)
contains + minContains/maxContains Как минимум/как максимум сколько совпадающих элементов должно присутствовать
uniqueItems Элементы должны все отличаться (глубокое сравнение; избегайте на больших массивах)
minItems / maxItems Ограничения длины

3.5 Объекты

properties, required, patternProperties, additionalProperties, propertyNames, minProperties, maxProperties, dependentRequired, dependentSchemas.

Три высокочастотных заблуждения:

  1. required требует только существования ключа: {"name": ""} валидно. Чтобы отвергнуть пустую строку, вы должны добавить minLength: 1;
  2. additionalProperties: false увеличивает проблемы совместимости: одно лишнее поле от клиента валит всю полезную нагрузку, поэтому эволюция API вынуждает каждого вызывающего обновляться синхронно;
  3. properties не ограничивает необъявленные ключи — по умолчанию они разрешены.

3.6 Логическая композиция

{
  "oneOf": [
    { "type": "object", "required": ["cardNumber"], "properties": { "cardNumber": { "type": "string" } } },
    { "type": "object", "required": ["accountId"], "properties": { "accountId": { "type": "string" } } }
  ]
}
Ключевое слово Семантика Типичный сбой
allOf Все должны выполняться Несколько веток накладывают конфликтующие ограничения на одно поле
anyOf Должна выполняться хотя бы одна Сообщение об ошибке трудно отследить до конкретной ветки
oneOf Ровно одна должна выполняться Данные совпадают с двумя ветками и падают, хотя «выглядят очевидно верными»
not Не должна выполняться Редко используется и наименее дружелюбные сообщения об ошибках

oneOf — ключевое слово, наиболее склонное производить «валидные данные, которые не проходят валидацию». Если ограничения веток перекрываются (например, обе разрешают лишние поля), одна запись совпадает с несколькими ветками, и oneOf падает начисто. В этом случае переключитесь на anyOf или используйте поле-дискриминатор с const внутри веток.

{
  "type": "object",
  "required": ["kind"],
  "properties": { "kind": { "enum": ["text", "image"] } },
  "if": { "properties": { "kind": { "const": "image" } } },
  "then": { "required": ["url", "width", "height"] },
  "else": { "required": ["content"] }
}

Стиль дискриминатора читаемее, чем oneOf, и даёт лучшие ошибки.

4. Разделение и переиспользование

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://bbdrug.com/schemas/order.json",
  "$defs": {
    "money": { "type": "string", "pattern": "^-?\\d+\\.\\d{2}$" },
    "address": {
      "type": "object",
      "required": ["city"],
      "properties": { "city": { "type": "string" }, "detail": { "type": "string" } }
    }
  },
  "type": "object",
  "required": ["total", "shipTo"],
  "properties": {
    "total": { "$ref": "#/$defs/money" },
    "shipTo": { "$ref": "#/$defs/address" }
  }
}
  • $defs — форма, введённая в 2019-09; draft-07 использует definitions;
  • Межфайловые ссылки используют относительные $id, но не рассчитывайте на выборку удалённых схем во время выполнения в продакшене — вместо этого объединяйте их в статические файлы;
  • Вы не можете добавить ограничения рядом со ссылкой (поведение $ref с соседними ключевыми словами различается по версии). Когда нужна композиция, используйте allOf.

5. Валидация во время выполнения с Ajv

import Ajv2020 from 'ajv/dist/2020';
import addFormats from 'ajv-formats';
import orderSchema from './order.json' with { type: 'json' };

const ajv = new Ajv2020({
  allErrors: true,
  strict: true,
  allowUnionTypes: true,
});
addFormats(ajv);

const validateOrder = ajv.compile(orderSchema);

export function assertOrder(input: unknown): void {
  if (validateOrder(input)) {
    return;
  }
  const detail = (validateOrder.errors ?? []).map((errorItem) => ({
    path: errorItem.instancePath || '/',
    keyword: errorItem.keyword,
    message: errorItem.message,
    params: errorItem.params,
  }));
  throw new Error(`Order data failed validation: ${JSON.stringify(detail)}`);
}

Ключевые моменты:

  • Вызывайте compile только один раз, на верхнем уровне модуля; повторная компиляция на каждый запрос превращает 10-мс валидацию в сотни миллисекунд;
  • strict: true рано выявляет опечатанные ключевые слова (например minlength вместо minLength), поэтому его стоит включить;
  • Только ajv-formats заставляет format: "email" и format: "date-time" действительно действовать;
  • 2020-12 требует входной точки ajv/dist/2020 — не используйте экспорт по умолчанию.

6. Как проектировать сообщения об ошибках

Валидатор даёт вам «какое ключевое слово провалилось»; пользователь хочет «какое поле ввода неверно». Стоит добавить слой отображения:

const FIELD_LABEL: Record<string, string> = {
  '/name': 'Name',
  '/age': 'Age',
  '/tags': 'Tags',
};

/**
 * Convert Ajv errors into display-ready messages.
 * @param errors The list of Ajv errors
 * @returns An array of display messages
 */
function toMessages(errors: Array<{ instancePath: string; keyword: string; params: unknown }>): string[] {
  return errors.map((errorItem) => {
    const label = FIELD_LABEL[errorItem.instancePath] ?? errorItem.instancePath;
    if (errorItem.keyword === 'required') {
      const missing = (errorItem.params as { missingProperty: string }).missingProperty;
      return `${label} is missing the required field ${missing}`;
    }
    if (errorItem.keyword === 'minLength') {
      return `${label} is too short`;
    }
    return `${label} failed validation (${errorItem.keyword})`;
  });
}

На стороне API держите машиночитаемую структуру, чтобы фронтенд мог решить, как её представить:

{
  "type": "https://bbdrug.com/problems/validation",
  "title": "Request data validation failed",
  "status": 422,
  "errors": [
    { "path": "/items/0/price", "keyword": "pattern", "message": "Must be a monetary string with two decimal places" }
  ]
}

7. Производительность и ловушки

  • Стоимость валидации растёт со сложностью схемы и размером данных; несколько веток oneOf вызывают комбинаторный взрыв, который становится очень заметным на тысячах записей;
  • uniqueItems выполняет O(n²) глубокое сравнение по массиву; для больших массивов дедуплицируйте в бизнес-слое;
  • Избегайте катастрофических шаблонов бэктрекинга в pattern (например (a+)+$); это классическая входная точка для ReDoS;
  • Когда additionalProperties: false сочетается с allOf, поля, объявленные в подсхеме, судятся родителем как «дополнительные», поэтому вам нужно слить их в единую схему или использовать unevaluatedProperties.

8. Контрольный список

  • [ ] Закрепите 2020-12 и объявите $schema в начале файла;
  • [ ] Извлекайте общие структуры через $defs + $ref вместо копирования-вставки;
  • [ ] Предпочитайте дискриминаторы вместо oneOf;
  • [ ] Переиспользуйте результат compile валидатора и включите строгий режим;
  • [ ] Ясно документируйте, проверяется ли format;
  • [ ] Ответы об ошибках предоставляют путь, ключевое слово и сообщение для пользователя;
  • [ ] Поместите схему в CI: запускайте тестовые случаи JSON Schema Test Suite как регрессионные тесты.

Вопросы и ответы

Как выбрать драфт JSON Schema?

Используйте 2020-12 для новых проектов и объявляйте $schema в начале файла. Если схема идёт в OpenAPI 3.1, она также должна быть 2020-12, потому что 3.1 встраивает эту версию напрямую; держите draft-07 только для проектов, всё ещё привязанных к старым инструментальным цепочкам. Два ведут себя по-разному для items и для соседних ключевых слов $ref, поэтому не смешивайте их в одном наборе схем.

Почему additionalProperties: false увеличивает проблемы совместимости?

Он трактует каждый необъявленный ключ как ошибку, поэтому одно лишнее поле от клиента валит всю полезную нагрузку. Тогда каждый вызывающий должен обновляться синхронно всякий раз, когда API эволюционирует, иначе старые клиенты отвергаются. Схемы в стиле конфигурации должны явно закрывать лишние поля, тогда как публичные API должны взвесить цену совместимости.

Использовать oneOf или anyOf?

Используйте oneOf, только когда данные должны совпасть ровно с одной веткой. Если ограничения веток могут выполняться одновременно, одна запись совпадает с несколькими ветками, и oneOf падает начисто; переключитесь на anyOf или добавьте дискриминатор const внутри каждой ветки.

Источники

Похожие статьи

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

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

Средний~11 мин чтенияОбновлено 2026-10-30

Без additionalProperties валидация схемы ничего не проверяет

Успешная валидация не значит чистые данные. Pydantic по умолчанию игнорирует неизвестные поля, Zod их отбрасывает, Ajv пропускает — выдуманные параметры и опечатки проходят тихо. Статья показывает три формы молчаливого принятия и даёт полные рецепты: закрывать объекты, контролировать приведение типов, подтягивать каждый уровень вложенности.

Intermediate~8 мин чтенияОбновлено 2026-11-02