Рекурсивные схемы

Во многих реальных приложениях данные имеют вложенную, потенциально бесконечную структуру. Типичные примеры — деревья категорий, комментарии с ответами, меню навигации, организационные иерархии. Для таких случаев обычные плоские схемы валидации оказываются недостаточными.

Валидация в подобных сценариях требует возможности описывать схему, которая ссылается сама на себя. Именно здесь появляется необходимость рекурсивного определения правил валидации.

Базовый подход к вложенным объектам

Библиотека Yup предоставляет механизм описания структуры объектов через object().shape():

import * as yup fr om 'yup';

const commentSchema = yup.object({
  id: yup.string().required(),
  text: yup.string().required(),
  author: yup.string().required(),
  replies: yup.array().of(
    yup.object({
      id: yup.string().required(),
      text: yup.string().required()
    })
  )
});

В этом примере replies уже задаёт вложенную структуру, однако глубина ограничена фиксированным описанием. Если уровень вложенности заранее неизвестен, такой подход становится недостаточным.

Проблема самоссылочных структур

Рассмотрим задачу: комментарий может содержать ответы, каждый из которых тоже может содержать ответы, и так далее без ограничения глубины.

Попытка прямого самоссылочного определения приводит к ошибке:

const schema = yup.object({
  text: yup.string(),
  replies: yup.array().of(schema) // ❌ schema ещё не определена полностью
});

Причина в том, что переменная schema используется до завершения её инициализации.

Механизм отложенного определения

Для решения этой проблемы используется yup.lazy. Он позволяет отложить создание схемы до момента валидации конкретного значения.

const commentSchema = yup.object({
  id: yup.string().required(),
  text: yup.string().required(),
  replies: yup.array().of(
    yup.lazy(() => commentSchema)
  )
});

Здесь важен принцип: схема возвращается функцией, что позволяет избежать циклической зависимости на этапе объявления.

Рекурсивные объекты через lazy

Более корректный и безопасный вариант строится через явное разделение определения:

const commentSchema = yup.lazy(() =>
  yup.object({
    id: yup.string().required(),
    text: yup.string().required(),
    replies: yup.array().of(commentSchema)
  })
);

На первый взгляд может показаться, что сохраняется рекурсия на уровне переменных, однако lazy гарантирует отложенное разрешение структуры во время выполнения, а не во время объявления.

Деревья произвольной глубины

Рекурсивные схемы особенно полезны при работе с деревьями:

const nodeSchema = yup.lazy(() =>
  yup.object({
    name: yup.string().required(),
    children: yup.array().of(nodeSchema).default([])
  })
);

Такое описание позволяет валидировать структуры вроде:

const data = {
  name: 'root',
  children: [
    {
      name: 'child 1',
      children: [
        { name: 'child 1.1', children: [] }
      ]
    }
  ]
};

Ограничение глубины рекурсии

Чисто рекурсивная схема может приводить к чрезмерно глубоким структурам. Для контроля глубины используется дополнительная логика через test.

const schema = yup.lazy(() =>
  yup.object({
    name: yup.string().required(),
    depth: yup.number().default(0),
    children: yup.array().of(
      yup.lazy(() =>
        schema.test('depth-lim it', 'Слишком глубокая вложенность', function (value) {
          if (value && value.depth > 5) {
            return false;
          }
          return true;
        })
      )
    )
  })
);

Хотя такой подход рабочий, он увеличивает сложность схемы и требует аккуратного управления метаданными.

Разделение модели и схемы

В более устойчивой архитектуре рекурсия выносится в отдельную фабрику схем, чтобы избежать замыкания на конкретную переменную:

const createNodeSchema = () =>
  yup.lazy(() =>
    yup.object({
      name: yup.string().required(),
      children: yup.array().of(createNodeSchema())
    })
  );

const nodeSchema = createNodeSchema();

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

Рекурсивные массивы и сложные структуры

Рекурсия может применяться не только к объектам, но и к комбинациям типов:

const schema = yup.lazy(() =>
  yup.mixed().test('recursive', 'Неверный формат', function (value) {
    if (Array.isArray(value)) {
      return value.every(v => schema.isValidSync(v));
    }

    if (typeof value === 'object' && value !== null) {
      return yup.object({
        value: yup.string(),
        next: schema
      }).isValidSync(value);
    }

    return typeof value === 'string';
  })
);

Такой подход используется редко из-за высокой сложности, но он демонстрирует гибкость механизма рекурсивной валидации.

Типовые ошибки при построении рекурсивных схем

Одной из распространённых проблем является преждевременное разрешение схемы:

const schema = yup.object({
  children: yup.array().of(schema) // ошибка
});

Также часто встречается бесконечная рекурсия при отсутствии базового случая. Любая рекурсивная схема должна иметь условие завершения, иначе валидация приведёт к переполнению стека.

Другой проблемой является избыточное использование lazy, которое усложняет отладку и ухудшает читаемость кода.

Производительность рекурсивной валидации

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

Оптимизация достигается за счёт:

  • ограничения глубины;
  • кэширования схем;
  • упрощения вложенных правил;
  • разделения валидации на этапы.

Композиция рекурсивных схем

Рекурсивные схемы могут быть частью более крупных структур:

const userSchema = yup.object({
  name: yup.string(),
  tree: nodeSchema,
  metadata: yup.object({
    updatedAt: yup.date()
  })
});

Это позволяет сочетать статические и динамические части модели, сохраняя гибкость структуры данных.

Использование в реальных сценариях

На практике рекурсивная валидация встречается в:

  • файловых системах и деревьях каталогов;
  • комментариях с ответами;
  • многоуровневых меню;
  • JSON-конфигурациях;
  • графоподобных структурах.

Во всех этих случаях ключевым является способность схемы описывать самоподобную структуру без заранее фиксированной глубины.

Особенности отладки

Отладка рекурсивных схем требует внимательного анализа пути ошибки. В Yup ошибки обычно содержат путь вида:

children[0].children[2].name

При глубокой вложенности такие пути становятся длинными, что усложняет диагностику. Поэтому часто добавляют дополнительные поля контекста:

yup.string().test('trace', function (value) {
  return this.createError({
    message: `Ошибка в узле: ${this.path}`
  });
});

Итоговая структура подхода

Рекурсивные схемы в Yup строятся вокруг трёх ключевых принципов:

  • отложенное определение через lazy;
  • наличие базового случая завершения;
  • контроль глубины и сложности структуры.

Их использование позволяет описывать сложные иерархические данные без жёсткого ограничения уровня вложенности, сохраняя при этом декларативный стиль валидации.