oneOf для взаимоисключения

Ключевое слово oneOf в JSON Schema используется для описания взаимоисключающих схем. Значение считается валидным только в том случае, если оно соответствует ровно одной схеме из списка.

Ajv полностью поддерживает oneOf и активно применяет его при валидации сложных структур данных: API, DTO, событий, конфигураций, polymorphic-объектов и discriminated unions.

Базовый синтаксис:

const schema = {
  oneOf: [
    { type: "string" },
    { type: "number" }
  ]
}

В этом примере:

  • строка проходит валидацию;
  • число проходит валидацию;
  • значение не должно соответствовать двум схемам одновременно.

Принцип работы oneOf

Ajv проверяет все схемы внутри массива oneOf.

Валидация считается успешной только если:

  • совпала ровно одна схема.

Ошибки возникают в двух случаях:

  • не совпала ни одна схема;
  • совпало более одной схемы.

Простая проверка типов

const Ajv = require("ajv")

const ajv = new Ajv()

const schema = {
  oneOf: [
    { type: "string" },
    { type: "integer" }
  ]
}

const validate = ajv.compile(schema)

console.log(validate("hello")) // true
console.log(validate(10))      // true
console.log(validate(3.14))    // false

Число 3.14 не является integer, поэтому не подходит ни под одну схему.


Взаимоисключение объектов

Наиболее частый сценарий — разные варианты структуры объекта.

Например, система поддерживает два типа пользователей:

  • физическое лицо;
  • компания.
const schema = {
  oneOf: [
    {
      type: "object",
      properties: {
        type: { const: "person" },
        firstName: { type: "string" },
        lastName: { type: "string" }
      },
      required: ["type", "firstName", "lastName"],
      additionalProperties: false
    },
    {
      type: "object",
      properties: {
        type: { const: "company" },
        companyName: { type: "string" },
        inn: { type: "string" }
      },
      required: ["type", "companyName", "inn"],
      additionalProperties: false
    }
  ]
}

Проверка:

validate({
  type: "person",
  firstName: "Ivan",
  lastName: "Petrov"
})
// true
validate({
  type: "company",
  companyName: "Tech Corp",
  inn: "123456789"
})
// true

Почему const особенно важен

Без уникального идентификатора схемы могут пересекаться.

Проблемный пример:

const schema = {
  oneOf: [
    {
      type: "object",
      properties: {
        name: { type: "string" }
      }
    },
    {
      type: "object",
      properties: {
        age: { type: "number" }
      }
    }
  ]
}

Объект:

{
  name: "Alex",
  age: 30
}

совпадёт сразу с двумя схемами:

  • первая схема допускает наличие name;
  • вторая допускает наличие age.

Результат:

false

Поскольку совпадений два, oneOf считает данные невалидными.


Отличие oneOf от anyOf

anyOf

Требует совпадения хотя бы одной схемы.

{
  anyOf: [
    { type: "string" },
    { type: "number" }
  ]
}

Если значение подходит под две схемы одновременно — ошибки нет.


oneOf

Требует совпадения ровно одной схемы.

{
  oneOf: [
    { type: "integer" },
    { type: "number" }
  ]
}

Проблема:

10
  • integer → true
  • number → true

Результат:

false

Типичная ошибка с наследованием типов

const schema = {
  oneOf: [
    { type: "number" },
    { minimum: 0 }
  ]
}

Положительное число:

100

подходит под обе схемы.

Результат:

false

Использование discriminator-поля

На практике почти всегда добавляется специальное поле-дискриминатор.

Например:

type
kind
role
event
action

Это позволяет сделать схемы полностью взаимоисключающими.


Полиморфные API-ответы

Пример ответа API:

{
  "type": "success",
  "data": {}
}

или

{
  "type": "error",
  "message": "Access denied"
}

Схема:

const schema = {
  oneOf: [
    {
      type: "object",
      properties: {
        type: { const: "success" },
        data: { type: "object" }
      },
      required: ["type", "data"],
      additionalProperties: false
    },
    {
      type: "object",
      properties: {
        type: { const: "error" },
        message: { type: "string" }
      },
      required: ["type", "message"],
      additionalProperties: false
    }
  ]
}

oneOf и массивы

Можно описывать взаимоисключающие варианты элементов массива.

const schema = {
  type: "array",
  items: {
    oneOf: [
      { type: "string" },
      { type: "number" }
    ]
  }
}

Допустимо:

["a", 1, "b", 2]

Недопустимо:

[true]

Проверка разных форматов команд

const schema = {
  oneOf: [
    {
      type: "object",
      properties: {
        action: { const: "create" },
        payload: { type: "object" }
      },
      required: ["action", "payload"]
    },
    {
      type: "object",
      properties: {
        action: { const: "delete" },
        id: { type: "number" }
      },
      required: ["action", "id"]
    }
  ]
}

Поведение ошибок в Ajv

По умолчанию Ajv возвращает общую ошибку:

console.log(validate.errors)

Пример:

[
  {
    instancePath: "",
    schemaPath: "#/oneOf",
    keyword: "oneOf",
    params: {
      passingSchemas: [0, 1]
    },
    message: "must match exactly one schema in oneOf"
  }
]

Включение подробных ошибок

const ajv = new Ajv({
  allErrors: true
})

Теперь можно увидеть ошибки каждой вложенной схемы.


Использование discriminator

Ajv поддерживает discriminator для ускорения выбора схемы.

const ajv = new Ajv({
  discriminator: true
})

Пример:

const schema = {
  discriminator: {
    propertyName: "type"
  },
  oneOf: [
    {
      properties: {
        type: { const: "cat" },
        meow: { type: "boolean" }
      },
      required: ["type", "meow"]
    },
    {
      properties: {
        type: { const: "dog" },
        bark: { type: "boolean" }
      },
      required: ["type", "bark"]
    }
  ]
}

Преимущества:

  • меньше лишних проверок;
  • быстрее валидация;
  • понятнее ошибки;
  • ближе к OpenAPI.

oneOf внутри свойств

const schema = {
  type: "object",
  properties: {
    value: {
      oneOf: [
        { type: "string" },
        { type: "number" }
      ]
    }
  }
}

Комбинация с required

const schema = {
  oneOf: [
    {
      required: ["email"]
    },
    {
      required: ["phone"]
    }
  ]
}

Проблема:

{
  email: "a@test.com",
  phone: "123"
}

Объект удовлетворяет обеим схемам.

Результат:

false

Решение через not

const schema = {
  oneOf: [
    {
      required: ["email"],
      not: {
        required: ["phone"]
      }
    },
    {
      required: ["phone"],
      not: {
        required: ["email"]
      }
    }
  ]
}

Теперь:

  • либо email;
  • либо phone;
  • одновременно нельзя.

Проверка разных структур событий

const schema = {
  oneOf: [
    {
      type: "object",
      properties: {
        event: { const: "login" },
        userId: { type: "number" }
      },
      required: ["event", "userId"]
    },
    {
      type: "object",
      properties: {
        event: { const: "purchase" },
        amount: { type: "number" }
      },
      required: ["event", "amount"]
    }
  ]
}

Использование с if/then/else

Иногда oneOf можно заменить условной логикой.

Вариант с oneOf

{
  oneOf: [
    {
      properties: {
        type: { const: "a" }
      }
    },
    {
      properties: {
        type: { const: "b" }
      }
    }
  ]
}

Вариант с if

{
  if: {
    properties: {
      type: { const: "a" }
    }
  },
  then: {
    required: ["fieldA"]
  },
  else: {
    required: ["fieldB"]
  }
}

Когда лучше использовать oneOf

oneOf особенно полезен в случаях:

  • polymorphic objects;
  • OpenAPI schemas;
  • discriminated unions;
  • разные типы событий;
  • разные DTO;
  • команды API;
  • разные структуры сообщений;
  • сериализация моделей.

Когда oneOf создаёт проблемы

Пересекающиеся схемы

Самая распространённая ошибка.

oneOf: [
  { type: "number" },
  { minimum: 10 }
]

Число 20 проходит обе схемы.


Слишком общие схемы

oneOf: [
  { type: "object" },
  {
    type: "object",
    required: ["id"]
  }
]

Любой объект с id совпадёт дважды.


Отсутствие additionalProperties: false

Без ограничения дополнительные поля позволяют схемам пересекаться.


Практический шаблон безопасного oneOf

Наиболее стабильный подход:

const schema = {
  oneOf: [
    {
      type: "object",
      properties: {
        kind: { const: "A" },
        valueA: { type: "string" }
      },
      required: ["kind", "valueA"],
      additionalProperties: false
    },
    {
      type: "object",
      properties: {
        kind: { const: "B" },
        valueB: { type: "number" }
      },
      required: ["kind", "valueB"],
      additionalProperties: false
    }
  ]
}

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

  • discriminator-поле;
  • const;
  • required;
  • additionalProperties: false.

Именно такая комбинация делает схемы действительно взаимоисключающими.


Производительность oneOf

Ajv вынужден проверять все схемы внутри oneOf, чтобы убедиться:

  • совпадение только одно.

При большом количестве вариантов это может быть дорого.

Особенно если:

  • схемы глубокие;
  • используются $ref;
  • есть вложенные oneOf;
  • присутствуют сложные регулярные выражения.

Оптимизация

Использование discriminator

Наиболее эффективный способ.


Исключение пересечений

Чем меньше неоднозначности между схемами, тем быстрее валидация.


Явные ограничения

Полезны:

const
enum
required
additionalProperties
type

Вложенные oneOf

const schema = {
  oneOf: [
    {
      properties: {
        payload: {
          oneOf: [
            { type: "string" },
            { type: "number" }
          ]
        }
      }
    },
    {
      properties: {
        payload: {
          type: "boolean"
        }
      }
    }
  ]
}

Подобные конструкции быстро усложняют диагностику ошибок.


Диагностика проблем

Полезные настройки Ajv:

const ajv = new Ajv({
  allErrors: true,
  verbose: true
})

Типичный паттерн для OpenAPI

const schema = {
  discriminator: {
    propertyName: "type"
  },
  oneOf: [
    {
      $ref: "#/definitions/Cat"
    },
    {
      $ref: "#/definitions/Dog"
    }
  ]
}

Важное правило проектирования

Схемы внутри oneOf должны быть:

  • независимыми;
  • непересекающимися;
  • явно различимыми.

Если между схемами возможна неоднозначность, oneOf почти всегда приведёт к трудноуловимым ошибкам валидации.