Сложные многошаговые формы

Многошаговые формы в веб-приложениях почти всегда требуют разделения общей схемы валидации на логические этапы. В Joi это достигается не через одну монолитную схему, а через композицию частичных схем, условную валидацию и аккуратное управление состоянием данных между шагами.

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


Многошаговая форма обычно состоит из последовательности независимых экранов:

  1. Основные данные пользователя
  2. Адрес и контактная информация
  3. Платёжные данные
  4. Дополнительные настройки

Вместо одной большой схемы создаются отдельные схемы для каждого шага:

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 позволяет объединять схемы в единую структуру без дублирования описаний.


Условная логика с when

Многошаговые формы часто содержат развилки: одни поля становятся обязательными только при определённых условиях.

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

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

Для этого используется 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 для ветвления логики
  • финальная валидация объединяет все шаги
  • кастомные проверки применяются на завершающем этапе
  • схемы могут динамически расширяться

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