Во многих реальных приложениях данные имеют вложенную, потенциально бесконечную структуру. Типичные примеры — деревья категорий, комментарии с ответами, меню навигации, организационные иерархии. Для таких случаев обычные плоские схемы валидации оказываются недостаточными.
Валидация в подобных сценариях требует возможности описывать схему, которая ссылается сама на себя. Именно здесь появляется необходимость рекурсивного определения правил валидации.
Библиотека 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)
)
});
Здесь важен принцип: схема возвращается функцией, что позволяет избежать циклической зависимости на этапе объявления.
Более корректный и безопасный вариант строится через явное разделение определения:
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()
})
});
Это позволяет сочетать статические и динамические части модели, сохраняя гибкость структуры данных.
На практике рекурсивная валидация встречается в:
Во всех этих случаях ключевым является способность схемы описывать самоподобную структуру без заранее фиксированной глубины.
Отладка рекурсивных схем требует внимательного анализа пути ошибки. В Yup ошибки обычно содержат путь вида:
children[0].children[2].name
При глубокой вложенности такие пути становятся длинными, что усложняет диагностику. Поэтому часто добавляют дополнительные поля контекста:
yup.string().test('trace', function (value) {
return this.createError({
message: `Ошибка в узле: ${this.path}`
});
});
Рекурсивные схемы в Yup строятся вокруг трёх ключевых принципов:
lazy;Их использование позволяет описывать сложные иерархические данные без жёсткого ограничения уровня вложенности, сохраняя при этом декларативный стиль валидации.