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"] означає «може бути null» |
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 усередині кожної гілки.