Повторне використання JSON Schema: $ref, $defs і композиція allOf/oneOf
Головне: $ref розвʼязує повторне використання, allOf і oneOf розвʼязують композицію, а складання обох — ось де все ламається. Охоплює $id, $defs, умовні конструкції, рекурсію, пакування й налагодження.

Коротко: спершу виділіть спільні структури в $defs через $ref, а потім тягніться до allOf/oneOf; коли гілки можуть збігтися одночасно, використовуйте anyOf або тег const, а oneOf залишайте для випадків, що справді вимагають рівно одного збігу.
Копіювати-вставляти заощаджує десять хвилин і коштує вам тихого розгалуження через три місяці, коли ніхто вже не памʼятає, яка копія правильна.
1. Три шари повторного використання — оберіть один, перш ніж ділити файли
Перший інстинкт, щойно поля починають множитися, — впхнути кожне обмеження в один файл. Це найшвидший спосіб випустити, але після пари сотень рядків кожну зміну поля треба віддзеркалити в трьох місцях. Пропустіть одне — і отримаєте «локально проходить, у виробництві повертає 500». Повторне використання означає написати факт один раз і посилатися на нього всюди інде.
JSON Schema пропонує три шари, кожен із різною областю:
| Шар | Носій | Область | Типове використання |
|---|---|---|---|
| Підструктура | $defs + $ref |
Один файл схеми | Адреса, гроші, пагінація, форми помилок |
| Файл | Окрема схема + $id |
Кілька файлів в одному сервісі | Сутності користувача, замовлення, товару |
| Бібліотека контрактів | Репозиторій схем + спакований артефакт | Кілька сервісів, кілька мов | Компоненти OpenAPI, опубліковані контракти |
Для більшості проєктів достатньо першого шару. Поділ на файли окупається, лише коли два сервіси справді поділяють той самий контракт або коли схема — опублікований артефакт, який споживає інша мова. Занадто ранній поділ робить розвʼязання $id і відносних шляхів важчим для налагодження, і ця ціна зазвичай перевищує заощаджене на уникненні дублювання.
Просте практичне правило: якщо структура зʼявляється у двох місцях, одна копія прийнятна; коли вона зʼявляється в третьому, підвищте її до $defs; лише коли інша мова чи репозиторій мусить її читати, вона заслуговує на окремий файл із власним $id.
2. $id встановлює ідентичність, $ref виконує стрибок
$id — не коментар. Він оголошує базовий URI схеми. Значення $ref — це посилання URI: воно розвʼязується відносно поточного базового URI в абсолютний URI, а потім фрагмент після # локалізує вузол. Якщо базовий URI неправильний, кожне відносне посилання вказує кудись інде, але помилка зазвичай каже лише «can't resolve reference», не називаючи неправильний хост.
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://bbdrug.com/schemas/order.json",
"$defs": {
"address": {
"$anchor": "address",
"type": "object",
"required": ["city"],
"properties": {
"city": { "type": "string", "minLength": 1 },
"detail": { "type": "string" }
}
}
},
"type": "object",
"required": ["shipTo"],
"properties": {
"shipTo": { "$ref": "#/$defs/address" },
"billTo": { "$ref": "#address" }
}
}
shipTo використовує JSON Pointer, а billTo — якір; обидва вказують на той самий вузол. Таблиця нижче відображає кожну форму на її розвʼязання:
| Форма | Вказує на | Чернетка | Примітки |
|---|---|---|---|
# |
Корінь поточної схеми | Усі | Поширено для рекурсії |
#/$defs/address |
Вузол під $defs |
2019-09 і пізніше | Найясніше; рекомендовано |
#address |
Іменований якір | $anchor у 2020-12 |
draft-07 писав $id: "#address" |
common.json |
Файл відносно базового URI | Усі | Треба зареєструвати або спакувати |
https://example.com/x.json |
Абсолютний URI | Усі | Уникайте завантаження під час виконання у виробництві |
Одна тонка пастка: якщо підсхема всередині $defs оголошує власний $id, він стає новим базовим URI для всього під ним. Вставите схему, копійовану з іншого файлу, і залишковий $id перенаправить її відносні посилання на оригінальний хост — а валідатор помітить це, лише коли справді спробує їх розвʼязати.
3. $defs — контейнер, а не місце перевірки
$defs — звичайна мапа. Ніщо всередині неї не набуває чинності, доки $ref туди не вкаже. Список required, що сидить на вершині $defs без вхідного посилання, не перевіряє рівно нічого, яким би строгим він не виглядав. Це специфікація працює як задумано, а не баг валідатора.
{
"$defs": {
"money": {
"type": "string",
"pattern": "^[0-9]+\\.[0-9]{2}$",
"description": "A two-decimal money string that avoids floating-point drift"
},
"page": { "type": "integer", "minimum": 1 }
},
"properties": {
"total": { "$ref": "#/$defs/money" },
"page": { "$ref": "#/$defs/page" }
}
}
Кілька конвенцій варто закріпити в команді:
- Ключі всередині
$defsне несуть семантики.address,Addressіaddrусі законні, тож домовтеся про стиль. - До 2019-09 ключове слово було
definitions. Обидва можна адресувати через$ref, але строгий режим може повідомитиdefinitionsяк невідоме ключове слово. $defsможе містити будь-яку схему, включно з фрагментомallOfабо незалежною підсхемою з власним$id.- Не трактуйте
$defsяк успадкування шаблонів. Він лише зберігає визначення; сенс усе ще походить із контексту-посилання.
4. allOf, anyOf і oneOf не взаємозамінні
Усі три задіюють кілька гілок, але їхні правила приймання різняться. Коли дані збігаються з двома гілками, allOf і anyOf проходять, а oneOf зазнає збою — найпоширеніша причина, чому люди роблять висновок «схема зламана».
| Ключове слово | Мінімум збігів | Максимум збігів | Дані, що збігаються з двома гілками | Якість помилок | Типове використання |
|---|---|---|---|---|---|
allOf |
Усі | Необмежено | Проходять | Добра, одне обмеження за раз | Обʼєднання фрагментів обмежень |
anyOf |
1 | Необмежено | Проходять | Погана, гілки накопичуються | «Email або телефон, будь-що одне» |
oneOf |
1 | 1 | Зазнають збою | Погана | Дискриміновані обʼєднання |
not |
0 | 0 | Зазнають збою | Найгірша | Виключення кількох значень; рідко використовується |
{
"allOf": [
{ "$ref": "#/$defs/auditable" },
{ "$ref": "#/$defs/softDeletable" },
{ "required": ["email"] }
],
"unevaluatedProperties": false
}
allOf не зливає визначення обʼєктів; він лише вимагає, щоб кожна гілка виконувалася. Кожна гілка обчислюється незалежно, тож additionalProperties: false усередині однієї гілки знає лише ключі, оголошені тією гілкою, і позначить поля, оголошені іншою гілкою, як зайві. У 2020-12 виправлення — покласти політику невідомих властивостей на верхній рівень і використати unevaluatedProperties: false, що обчислюється один раз після всіх гілок.
Використовуйте anyOf для «кількох законних форм», а не для «кількох взаємно виключних форм». Якщо дві гілки можуть збігтися одночасно, oneOf дає сумнозвісно некорисне must match exactly one schema in oneOf.
5. if/then/else і дискримінатор: зробіть умови вирішуваними
Умовні обмеження зазвичай дають кращі помилки, ніж oneOf, бо шлях рішення явний: if проходить — застосовується then; інакше застосовується else.
{
"type": "object",
"required": ["kind"],
"properties": { "kind": { "enum": ["text", "image"] } },
"if": {
"properties": { "kind": { "const": "image" } },
"required": ["kind"]
},
"then": { "required": ["url", "width", "height"] },
"else": { "required": ["content"] }
}
Три деталі визначають, чи поводиться воно як задумано:
- Запис
propertiesусерединіifне зазнає збою, коли ключ відсутній. Поєднайте його зrequired, щоб виразити «kind існує й дорівнює image». - Сам
ifніколи не продукує помилку перевірки. Його збій просто направляє доelse, тож він не може сам блокувати брудні дані. - Одинокий
thenчиelseінертний. Вони застосовуються лише поряд ізif.
Зі зростанням гілок обʼєднання умовний ланцюг стає несупроводжуваним гніздом. Стійкіший шаблон — закріпити тегове поле в кожній гілці, щоб гілки були взаємно виключними за конструкцією:
{
"oneOf": [
{
"properties": { "kind": { "const": "card" } },
"required": ["kind", "cardNumber"]
},
{
"properties": { "kind": { "const": "account" } },
"required": ["kind", "accountId"]
}
]
}
discriminator — розширення OpenAPI 3.x, а не ключове слово JSON Schema. Ajv підтримує його, коли ввімкнено опцію discriminator: true, але його відображення живе в mapping і не переносне між інструментаріями. Якщо схема мусить працювати в кількох валідаторах, надавайте перевагу шаблону тегу const, що не залежить від жодного розширення.
6. Рекурсивні посилання: дерева, звʼязані списки й файли, що цитують одне одного
Рекурсивні структури не потребують спеціального синтаксису — достатньо $ref на себе. Валідатори не розгортають посилання нескінченно під час компіляції; вони рекурсують за фактичною глибиною даних, тож кілька десятків рядків описують дерево будь-якої висоти.
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://bbdrug.com/schemas/tree-node.json",
"$defs": {
"node": {
"type": "object",
"required": ["name"],
"properties": {
"name": { "type": "string", "minLength": 1 },
"children": {
"type": "array",
"items": { "$ref": "#/$defs/node" }
}
}
}
},
"$ref": "#/$defs/node"
}
Три межі варто вирішити заздалегідь:
- Глибина даних: схема не накладає межі, але перевірка й серіалізація споживають стек пропорційно глибині. Публічні API мають додати бізнес-межу, скажімо вісім рівнів, інакше зловмисно глибоке навантаження може переповнити стек.
- Взаємні посилання: A посилається на B, а B — на A, це законно, але обидві схеми треба зареєструвати спершу; інакше порядок розвʼязання вирішує результат.
- Гілки, що можуть бути null: якщо рекурсивне поле може бути
null, пишіть"type": ["object", "null"]або обгортайте вoneOf.$refне успадковує можливість бути null.
7. Міжфайлові посилання й пакування: ніколи не завантажуйте схеми у виробництві
Міжфайлові посилання часто «просто працюють» у розробці, бо валідатор завантажує ціль за $id по мережі. Змініть мережеву політику під час розгортання — і весь ланцюг перевірки ламається. Виробниче правило просте: кожна схема, на яку є посилання, мусить бути зареєстрована в процесі або спакована заздалегідь.
import Ajv2020 from 'ajv/dist/2020';
import addressSchema from './address.json' with { type: 'json' };
import orderSchema from './order.json' with { type: 'json' };
const ajv = new Ajv2020({ allErrors: true, strict: true });
// Спершу зареєструйте файл, на який є посилання; ключ має дорівнювати його $id, інакше відносні посилання не розвʼяжуться
ajv.addSchema(addressSchema, 'https://bbdrug.com/schemas/address.json');
// Скомпілюйте вхідну схему один раз, на рівні модуля
export const validateOrder = ajv.compile(orderSchema);
Якщо order.json має $id: "https://bbdrug.com/schemas/order.json", його "$ref": "address.json" розвʼязується в https://bbdrug.com/schemas/address.json, що збігається із зареєстрованим ключем. Один відмінний символ дає can't resolve reference.
Три практичні підходи порівнюються так:
| Підхід | Як | Сильна сторона | Ціна |
|---|---|---|---|
| Реєстрація під час виконання | addSchema для кожного файлу |
Без кроку збірки, файли лишаються незалежними | Порядок реєстрації й ключі вручну |
| Пакування під час збірки | Злити парсером посилань в один файл | Нуль залежностей під час виконання | Більший артефакт, шумні diff |
| Асинхронне завантаження | loadSchema + compileAsync |
Обробляє часто змінювані схеми | Мережа й кеш; уникайте на критичних шляхах |
Для фронтенд-проєктів пакування під час збірки безпечніше. На сервері достатньо реєстрації за умови, що тест доводить розвʼязання кожного посилання. Так чи інакше запускайте compile у CI, щоб помилки посилань виявлялися до злиття, а не на першому виробничому запиті.
8. Порядок налагодження: розвʼязати, скомпілювати, потім оглянути гілки
Майже будь-яку проблему $ref можна локалізувати в три етапи, і порядок важливий:
| Симптом | Імовірна причина | Виправлення |
|---|---|---|
can't resolve reference ... from id ... |
Друкарська помилка в $id, неправильна відносна база, ціль не зареєстрована |
Виведіть $id і розвʼязаний абсолютний URI, потім порівняйте із зареєстрованими ключами |
schema with key or id already exists |
Той самий $id зареєстровано двічі або compile викликано повторно |
Перенесіть реєстрацію в ініціалізацію модуля й виконайте один раз |
strict mode: unknown keyword: "definitions" |
Змішування синтаксису draft-07 | Перейменуйте на $defs; не вимикайте строгий режим |
Дані збігаються з двома гілками oneOf |
Перетин обмежень гілок, немає тегового поля | Додайте const у кожну гілку або перемкніться на anyOf |
Обмеження всередині $defs не діють |
Ніщо на них не посилається | Перевірте шлях посилання; $defs сам собою нічого не перевіряє |
Під час налагодження читайте schemaPath перед message: він називає гілку й ключове слово, що зазнали збою, і набагато надійніший за англійську прозу. Кешуйте скомпільований валідатор на рівні модуля замість повторної компіляції на кожен запит. Для кожної зміни схеми покривайте і прохідний, і збійний випадок — перевірка лише щасливого шляху не перевіряє нічого. Про вибір валідатора й дизайн повідомлень про помилки дивіться JSON Schema: від основ до виробництва; про вплетення схеми в генерацію типів дивіться Двостороння синхронізація між Schema і типами TypeScript.
9. Часті питання
Чи може $ref стояти поряд із type або description?
2020-12 дозволяє $ref поряд із сусідніми ключовими словами, і ці сусіди обчислюються нормально. У draft-07 сусіди ігноруються, і застосовується лише $ref. Це найбільша поведінкова різниця, коли та сама схема використовується між чернетками, тож підтвердьте, яку чернетку підтримує ваш валідатор, перш ніж мігрувати.
Використовувати $defs чи definitions?
Використовуйте $defs для нової роботи; воно зʼявилося з 2019-09. definitions не є ключовим словом у 2020-12. Покажчик на кшталт $ref: "#/definitions/foo" усе ще може розвʼязатися, бо це звичайний JSON Pointer, але строгий режим повідомить невідоме ключове слово. Змішування обох стилів залишає міну, що вибухає лише в CI.
Чому oneOf відхиляє дані, що виглядають валідними?
Бо дані задовольняють дві гілки одночасно. oneOf вимагає рівно одну, а гілка, що обмежує лише частину обʼєкта, задовольняється, навіть коли поля іншої гілки відсутні. Додайте тег const у кожну гілку, щоб зробити їх взаємно виключними.
Чи зацикляться рекурсивні посилання назавжди?
Ні. Валідатори не розгортають посилання під час компіляції; вони рекурсують над фактичними даними. Справжні ризики — дані, достатньо глибокі, щоб переповнити стек, і неправильний порядок розвʼязання між файлами з взаємними посиланнями. Бізнес-межа глибини на рекурсивних полях ефективніша за будь-який трюк зі схемою.
Чи може виробництво покладатися на віддалений $ref?
Ні. Віддалені посилання привʼязують доступність перевірки до мережі: зміни домену, прострочені сертифікати й ізоляція внутрішньої мережі — усе це ламає ланцюг. Спакуйте схеми, на які є посилання, в артефакт або зареєструйте їх під час запуску, якщо хочете передбачуваної поведінки.
Як вирішити між $defs і окремими файлами?
Структуру, що зʼявляється у двох місцях, можна копіювати один раз; коли вона зʼявляється в третьому місці, підвищте її до $defs. Лише коли інша мова чи інший репозиторій мусить її читати, вона заслуговує на окремий файл із власним $id.
Чому обмеження всередині $defs не діють?
$defs — лише контейнер, а вузли, на які не вказує жоден $ref, ніколи не беруть участі в перевірці. Список required на вершині $defs без вхідного посилання все одно пропускає дані — це специфікація, а не баг валідатора.
Чи ставити additionalProperties: false усередині гілок allOf?
Ні. Кожна гілка обчислюється незалежно, тож одна гілка вважає поля, оголошені іншою гілкою, зайвими. Підхід 2020-12 переносить політику невідомих полів на верхній рівень і використовує unevaluatedProperties: false, що обчислюється один раз після всіх гілок.