JSON Toolkit

Повторне використання JSON Schema: $ref, $defs і композиція allOf/oneOf

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

Середній~11 хв читанняОпубліковано 2026-10-31Оновлено 2026-10-31
Діаграма повторного використання й композиції JSON Schema через $ref, $defs і allOf/oneOf

Коротко: спершу виділіть спільні структури в $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"] }
}

Три деталі визначають, чи поводиться воно як задумано:

  1. Запис properties усередині if не зазнає збою, коли ключ відсутній. Поєднайте його з required, щоб виразити «kind існує й дорівнює image».
  2. Сам if ніколи не продукує помилку перевірки. Його збій просто направляє до else, тож він не може сам блокувати брудні дані.
  3. Одинокий 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, що обчислюється один раз після всіх гілок.