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

Главное: сначала закрепите $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.
Три высокочастотных заблуждения:
requiredтребует только существования ключа:{"name": ""}валидно. Чтобы отвергнуть пустую строку, вы должны добавитьminLength: 1;additionalProperties: falseувеличивает проблемы совместимости: одно лишнее поле от клиента валит всю полезную нагрузку, поэтому эволюция API вынуждает каждого вызывающего обновляться синхронно;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 внутри каждой ветки.