Создание переиспользуемых схем

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

Связка Yup и yupResolver позволяет строить переиспользуемые схемы, разделять правила на независимые модули и собирать сложную валидацию из небольших компонентов.


Базовая структура переиспользуемой схемы

Наиболее простой вариант — вынесение схемы в отдельный файл.

user.schema.js

import * as yup from 'yup';

export const userSchema = yup.object({
  name: yup
    .string()
    .required('Имя обязательно')
    .min(2, 'Минимум 2 символа'),

  email: yup
    .string()
    .email('Некорректный email')
    .required('Email обязателен'),
});

Использование в React Hook Form

import { useForm } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';

import { userSchema } from './schemas/user.schema';

const {
  register,
  handleSubmit,
  formState: { errors },
} = useForm({
  resolver: yupResolver(userSchema),
});

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


Выделение общих правил валидации

Повторяются не только целые схемы, но и отдельные поля:

  • email;
  • пароль;
  • телефон;
  • URL;
  • идентификаторы;
  • даты.

common.rules.js

import * as yup from 'yup';

export const emailRule = yup
  .string()
  .email('Некорректный email')
  .required('Email обязателен');

export const passwordRule = yup
  .string()
  .required('Пароль обязателен')
  .min(8, 'Минимум 8 символов')
  .matches(/[A-Z]/, 'Нужна заглавная буква')
  .matches(/[0-9]/, 'Нужна цифра');

export const phoneRule = yup
  .string()
  .matches(/^\+?[0-9]{11,14}$/, 'Некорректный телефон');

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

import * as yup from 'yup';

import {
  emailRule,
  passwordRule,
  phoneRule,
} from './common.rules';

export const registerSchema = yup.object({
  email: emailRule,
  password: passwordRule,
  phone: phoneRule,
});

Композиция схем

Yup поддерживает объединение схем через .shape() и .concat().

Использование .shape()

import * as yup from 'yup';

const baseUserSchema = yup.object({
  name: yup.string().required(),
  email: yup.string().email().required(),
});

const extendedUserSchema = baseUserSchema.shape({
  age: yup.number().required().positive(),
});

Схема extendedUserSchema содержит:

  • name;
  • email;
  • age.

Использование .concat()

Метод .concat() объединяет две схемы.

const addressSchema = yup.object({
  city: yup.string().required(),
  street: yup.string().required(),
});

const profileSchema = baseUserSchema.concat(addressSchema);

Это особенно полезно при модульной архитектуре.


Создание фабрик схем

Во многих случаях схема зависит от параметров:

  • режима формы;
  • роли пользователя;
  • конфигурации;
  • локализации;
  • состояния приложения.

Для этого удобно использовать функции-фабрики.

Схема с параметрами

import * as yup from 'yup';

export const createPasswordSchema = (isStrongPassword) => {
  return yup.object({
    password: isStrongPassword
      ? yup
          .string()
          .required()
          .min(12)
          .matches(/[A-Z]/)
          .matches(/[0-9]/)
      : yup
          .string()
          .required()
          .min(6),
  });
};

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

const schema = createPasswordSchema(true);

useForm({
  resolver: yupResolver(schema),
});

Переиспользование вложенных схем

В крупных приложениях вложенные объекты появляются постоянно:

  • адреса;
  • контакты;
  • настройки;
  • банковские реквизиты;
  • профили.

Вынесение вложенной схемы

address.schema.js

import * as yup from 'yup';

export const addressSchema = yup.object({
  city: yup.string().required(),
  street: yup.string().required(),
  zipCode: yup.string().required(),
});

user.schema.js

import * as yup from 'yup';

import { addressSchema } from './address.schema';

export const userSchema = yup.object({
  name: yup.string().required(),
  address: addressSchema,
});

Переиспользование массивов

Схемы элементов массива

import * as yup from 'yup';

export const productSchema = yup.object({
  title: yup.string().required(),
  price: yup.number().required().positive(),
});

Массив объектов

import * as yup from 'yup';

import { productSchema } from './product.schema';

export const cartSchema = yup.object({
  products: yup.array().of(productSchema),
});

Такой подход особенно полезен при работе с:

  • useFieldArray;
  • динамическими таблицами;
  • списками товаров;
  • редакторами данных.

Расширение схем через helper-функции

Повторяющиеся модификации схем удобно оформлять как функции.

Добавление обязательности

import * as yup from 'yup';

export const requiredString = (message = 'Поле обязательно') =>
  yup.string().required(message);

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

const schema = yup.object({
  firstName: requiredString(),
  lastName: requiredString(),
});

Ограничение длины

export const createLimitedString = (max) =>
  yup.string().max(max);

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

const schema = yup.object({
  description: createLimitedString(300),
});

Универсальные схемы для CRUD

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

Базовая схема

const baseProductSchema = {
  title: yup.string().required(),
  price: yup.number().required(),
};

Create Schema

export const createProductSchema = yup.object({
  ...baseProductSchema,

  image: yup
    .mixed()
    .required('Изображение обязательно'),
});

Update Schema

export const updateProductSchema = yup.object({
  ...baseProductSchema,

  image: yup.mixed(),
});

Такой подход избавляет от копирования одинаковых полей.


Наследование ограничений

Иногда требуется расширять существующие правила.

Исходное правило

const usernameRule = yup
  .string()
  .required()
  .min(3);

Расширенное правило

const adminUsernameRule = usernameRule.matches(
  /^admin_/,
  'Должен начинаться с admin_'
);

Динамическая генерация схем

В административных системах поля могут приходить с сервера.

Конфигурация полей

const fields = [
  {
    name: 'title',
    type: 'string',
    required: true,
  },
  {
    name: 'price',
    type: 'number',
    required: true,
  },
];

Генерация схемы

import * as yup from 'yup';

const generateSchema = (fields) => {
  const shape = {};

  fields.forEach((field) => {
    let validator;

    switch (field.type) {
      case 'string':
        validator = yup.string();
        break;

      case 'number':
        validator = yup.number();
        break;

      default:
        validator = yup.mixed();
    }

    if (field.required) {
      validator = validator.required();
    }

    shape[field.name] = validator;
  });

  return yup.object(shape);
};

Создание модульной структуры проекта

В больших приложениях схемы быстро разрастаются. Удобно организовывать их по доменам.

Пример структуры

src/
├── schemas/
│   ├── common/
│   │   ├── rules.js
│   │   ├── messages.js
│   │   └── helpers.js
│   │
│   ├── user/
│   │   ├── user.schema.js
│   │   ├── profile.schema.js
│   │   └── address.schema.js
│   │
│   ├── product/
│   │   ├── product.schema.js
│   │   └── category.schema.js
│   │
│   └── order/
│       ├── order.schema.js
│       └── payment.schema.js

Переиспользование сообщений об ошибках

Текст ошибок тоже желательно централизовать.

messages.js

export const validationMessages = {
  required: 'Поле обязательно',
  invalidEmail: 'Некорректный email',
  minPassword: 'Минимум 8 символов',
};

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

import * as yup from 'yup';

import { validationMessages } from './messages';

const schema = yup.object({
  email: yup
    .string()
    .email(validationMessages.invalidEmail)
    .required(validationMessages.required),
});

Интернационализация схем

Yup поддерживает глобальную локализацию.

Настройка setLocale

import * as yup from 'yup';

yup.setLocale({
  mixed: {
    required: 'Поле обязательно',
  },

  string: {
    email: 'Некорректный email',
    min: 'Слишком короткое значение',
  },
});

После этого сообщения автоматически применяются ко всем схемам.


Переиспользование условной логики

Базовое условие

const paymentSchema = yup.object({
  paymentMethod: yup.string().required(),

  cardNumber: yup.string().when('paymentMethod', {
    is: 'card',

    then: (schema) =>
      schema.required('Введите номер карты'),

    otherwise: (schema) =>
      schema.notRequired(),
  }),
});

Вынесение условия

export const cardRequiredRule = yup.string().when(
  'paymentMethod',
  {
    is: 'card',

    then: (schema) =>
      schema.required(),

    otherwise: (schema) =>
      schema.notRequired(),
  }
);

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

const schema = yup.object({
  paymentMethod: yup.string().required(),
  cardNumber: cardRequiredRule,
});

Переиспользуемые кастомные тесты

Создание теста

const slugRule = yup
  .string()
  .test(
    'slug-format',
    'Некорректный slug',
    (value) => {
      if (!value) return false;

      return /^[a-z0-9-]+$/.test(value);
    }
  );

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

const articleSchema = yup.object({
  slug: slugRule,
});

Расширение Yup через addMethod

Для действительно глобального переиспользования Yup позволяет добавлять собственные методы.

Создание метода

import * as yup from 'yup';

yup.addMethod(
  yup.string,
  'phone',
  function phone(message) {
    return this.matches(
      /^\+?[0-9]{11,14}$/,
      message || 'Некорректный телефон'
    );
  }
);

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

const schema = yup.object({
  phone: yup
    .string()
    .phone(),
});

Переиспользуемые схемы и TypeScript

При использовании TypeScript схемы становятся источником типов.

InferType

import * as yup from 'yup';

const userSchema = yup.object({
  name: yup.string().required(),
  age: yup.number().required(),
});

type User = yup.InferType<typeof userSchema>;

Тип User автоматически синхронизируется со схемой.


Избежание циклических зависимостей

При большом количестве схем легко получить циклические импорты.

Проблемный вариант

user.schema.js
  └── import profile.schema.js

profile.schema.js
  └── import user.schema.js

Решение

  • выносить общие правила в common;
  • избегать взаимных импортов;
  • хранить базовые схемы отдельно;
  • использовать фабрики;
  • разделять доменные области.

Производительность переиспользуемых схем

Создание схем внутри компонента приводит к лишним вычислениям.

Нежелательный вариант

const Component = () => {
  const schema = yup.object({
    name: yup.string().required(),
  });

  useForm({
    resolver: yupResolver(schema),
  });
};

Схема создаётся при каждом рендере.


Оптимальный вариант

const schema = yup.object({
  name: yup.string().required(),
});

const Component = () => {
  useForm({
    resolver: yupResolver(schema),
  });
};

Мемоизация схем

Если схема зависит от параметров:

const schema = useMemo(() => {
  return createUserSchema(role);
}, [role]);

Это предотвращает повторную генерацию схемы.


Переиспользование схем между frontend и backend

Yup можно использовать не только в React Hook Form.

Frontend

useForm({
  resolver: yupResolver(userSchema),
});

Backend

await userSchema.validate(data);

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

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

Организация schema builders

В крупных проектах удобно использовать отдельные builders.

Пример builder

export const buildUserSchema = ({
  requirePhone,
  requireAddress,
}) => {
  return yup.object({
    name: yup.string().required(),

    phone: requirePhone
      ? yup.string().required()
      : yup.string(),

    address: requireAddress
      ? addressSchema.required()
      : addressSchema,
  });
};

Стратегии масштабирования схем

Подход “маленьких блоков”

Каждое правило — независимый модуль:

emailRule
passwordRule
phoneRule

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

  • высокая гибкость;
  • лёгкое тестирование;
  • простое переиспользование.

Подход “готовых схем”

Схема представляет полноценную бизнес-сущность:

userSchema
productSchema
orderSchema

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

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

Гибридный подход

Наиболее распространённая архитектура:

  • базовые правила;
  • составные схемы;
  • фабрики;
  • builders;
  • локальные расширения.

Именно такой подход обеспечивает масштабируемость крупных приложений на React Hook Form и YupResolver.