Многошаговые формы в веб-приложениях почти всегда требуют разделения общей схемы валидации на логические этапы. В Joi это достигается не через одну монолитную схему, а через композицию частичных схем, условную валидацию и аккуратное управление состоянием данных между шагами.
Ключевая идея заключается в том, что каждый шаг формы валидируется отдельно, но при этом все шаги должны собираться в единую согласованную модель данных. Joi предоставляет достаточно инструментов, чтобы построить такую систему без усложнения логики приложения.
Многошаговая форма обычно состоит из последовательности независимых экранов:
Вместо одной большой схемы создаются отдельные схемы для каждого шага:
import Joi from 'joi';
const step1Schema = Joi.object({
firstName: Joi.string().min(2).max(50).required(),
lastName: Joi.string().min(2).max(50).required(),
email: Joi.string().email().required()
});
const step2Schema = Joi.object({
country: Joi.string().required(),
city: Joi.string().required(),
address: Joi.string().min(5).required()
});
const step3Schema = Joi.object({
cardNumber: Joi.string().creditCard().required(),
expiry: Joi.string().pattern(/^\d{2}\/\d{2}$/).required(),
cvv: Joi.string().length(3).required()
});
Такое разделение позволяет изолировать ошибки и упростить обработку данных на каждом этапе.
На каждом шаге пользователь отправляет только часть данных, поэтому важно валидировать не всю модель, а только актуальный сегмент.
const validateStep = async (schema, data) => {
return schema.validateAsync(data, {
abortEarly: false,
stripUnknown: true
});
};
Флаг abortEarly: false позволяет собрать все ошибки шага
сразу, а stripUnknown очищает лишние поля, предотвращая
утечку данных между шагами.
Пример использования:
await validateStep(step1Schema, req.body);
На этом уровне не требуется знать о других шагах — это критически важно для масштабируемости.
Так как данные приходят поэтапно, необходимо сохранять промежуточное состояние. Обычно это делается на сервере (сессия, Redis, база данных) или на клиенте.
Логика объединения может выглядеть следующим образом:
const formState = {
step1: {},
step2: {},
step3: {}
};
function saveStep(step, data) {
formState[step] = {
...formState[step],
...data
};
}
function getFullPayload() {
return {
...formState.step1,
...formState.step2,
...formState.step3
};
}
После завершения всех шагов можно выполнить финальную проверку полной модели:
const fullSchema = step1Schema
.concat(step2Schema)
.concat(step3Schema);
await fullSchema.validateAsync(getFullPayload(), {
abortEarly: false
});
Метод concat позволяет объединять схемы в единую
структуру без дублирования описаний.
Многошаговые формы часто содержат развилки: одни поля становятся обязательными только при определённых условиях.
Joi предоставляет when, позволяющий строить зависимую
валидацию.
Пример: платёжный метод влияет на обязательные поля:
const paymentSchema = Joi.object({
method: Joi.string().valid('card', 'paypal').required(),
cardNumber: Joi.when('method', {
is: 'card',
then: Joi.string().creditCard().required(),
otherwise: Joi.forbidden()
}),
paypalEmail: Joi.when('method', {
is: 'paypal',
then: Joi.string().email().required(),
otherwise: Joi.forbidden()
})
});
В контексте многошаговой формы это позволяет объединить разные сценарии в одной схеме шага, не создавая дублирующие структуры.
В более сложных сценариях шаги могут быть не фиксированными. Например, пользователь выбирает тип аккаунта, и форма меняется.
Для этого используется alternatives:
const accountSchema = Joi.alternatives().conditional('type', [
{
is: 'personal',
then: Joi.object({
type: Joi.string().valid('personal').required(),
firstName: Joi.string().required(),
lastName: Joi.string().required()
})
},
{
is: 'business',
then: Joi.object({
type: Joi.string().valid('business').required(),
companyName: Joi.string().required(),
taxId: Joi.string().required()
})
}
]);
Такой подход позволяет описывать разные ветки формы в одном месте, сохраняя строгую типизацию данных.
В многошаговых формах часто возникает необходимость повторной проверки уже пройденных шагов при изменении поздних данных.
Например, изменение страны может повлиять на допустимые адреса.
Для этого применяется повторная валидация всей накопленной модели:
async function revalidateAll(state) {
const fullData = {
...state.step1,
...state.step2,
...state.step3
};
return fullSchema.validateAsync(fullData, {
abortEarly: false
});
}
Это предотвращает ситуацию, когда ранние шаги становятся невалидными после изменения логики позже.
Иногда стандартных правил недостаточно, и требуется бизнес-валидация.
const schema = Joi.object({
age: Joi.number().min(18).required(),
guardianConsent: Joi.boolean().when('age', {
is: Joi.number().less(18),
then: Joi.valid(true).required(),
otherwise: Joi.forbidden()
})
}).custom((value, helpers) => {
if (value.age < 18 && !value.guardianConsent) {
return helpers.error('any.invalid');
}
return value;
});
Кастомная проверка полезна на финальном шаге, когда требуется учесть взаимосвязи между несколькими частями формы.
Иногда шаги формируются динамически, и схема должна собираться на лету.
function buildSchema(options) {
let schema = Joi.object({
email: Joi.string().email().required()
});
if (options.includePhone) {
schema = schema.keys({
phone: Joi.string().min(10).required()
});
}
if (options.includeAddress) {
schema = schema.keys({
address: Joi.string().required()
});
}
return schema;
}
Такой подход позволяет масштабировать форму без переписывания всей логики валидации.
Основная проблема многошаговых форм — рассинхронизация данных. Например, пользователь изменил имя на шаге 1, но не дошёл до финальной отправки.
Решение заключается в том, чтобы:
const stepValidationPipeline = [
step1Schema,
step2Schema,
step3Schema
];
async function validatePipeline(dataBySteps) {
for (let i = 0; i < stepValidationPipeline.length; i++) {
await stepValidationPipeline[i].validateAsync(dataBySteps[i], {
abortEarly: false
});
}
}
Многошаговые формы почти всегда работают с неполными объектами. Joi по умолчанию ожидает полную структуру, поэтому важно использовать опциональность грамотно.
const partialSchema = Joi.object({
firstName: Joi.string().min(2),
lastName: Joi.string().min(2)
}).min(1);
Это позволяет валидировать даже частично заполненные шаги без ошибок структуры.
Для удобства интерфейса важно возвращать ошибки строго текущего шага:
try {
await step2Schema.validateAsync(data);
} catch (err) {
return {
step: 2,
errors: err.details.map(d => ({
field: d.path.join('.'),
message: d.message
}))
};
}
Такой подход позволяет интерфейсу точно отображать ошибки без необходимости анализировать всю модель.
В сложных формах поля могут пересекаться между шагами. Например, email используется и для логина, и для уведомлений.
Чтобы избежать конфликтов, используется единая базовая схема:
const baseSchema = Joi.object({
email: Joi.string().email().required()
});
const authSchema = baseSchema.keys({
password: Joi.string().min(8).required()
});
const notificationSchema = baseSchema.keys({
newsletter: Joi.boolean()
});
Это предотвращает дублирование логики и расхождение требований.
Многошаговая форма с Joi обычно строится по следующим принципам:
when и alternatives для
ветвления логикиТакой подход обеспечивает предсказуемость, масштабируемость и строгую проверку данных на всех этапах взаимодействия пользователя с системой.