Создание кастомных методов валидации

Стандартных методов библиотеки Yup часто достаточно для проверки типовых сценариев:

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

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

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

Для решения подобных задач используются кастомные методы валидации.


Метод test()

Базовый механизм создания пользовательской проверки — метод test().

Общий синтаксис:

yup.string().test(
  name,
  message,
  testFunction
)

Параметры:

Параметр Описание
name Уникальное имя проверки
message Текст ошибки
testFunction Функция проверки

Простейшая кастомная проверка

Проверка строки на наличие слова admin.

import * as yup from 'yup';

const schema = yup.string().test(
  'contains-admin',
  'Строка должна содержать слово admin',
  value => {
    return value.includes('admin');
  }
);

Проверка:

schema.validate('super-admin');

Результат:

'super-admin'

Ошибка:

schema.validate('manager');

Результат:

ValidationError: Строка должна содержать слово admin

Возврат boolean

Функция проверки должна возвращать:

  • true — проверка пройдена;
  • false — ошибка валидации.
const schema = yup.string().test(
  'only-uppercase',
  'Допустимы только заглавные буквы',
  value => /^[A-Z]+$/.test(value)
);

Работа с undefined и null

Кастомная проверка вызывается даже при отсутствии значения.

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

const schema = yup.string().test(
  'check-length',
  'Минимум 5 символов',
  value => value.length >= 5
);

Ошибка:

Cannot read properties of undefined

Правильный вариант:

const schema = yup.string().test(
  'check-length',
  'Минимум 5 символов',
  value => {
    if (!value) return true;

    return value.length >= 5;
  }
);

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

Внутри функции проверки доступен контекст через this.

const schema = yup.string().test(
  'debug',
  'Ошибка',
  function(value) {
    console.log(this);

    return true;
  }
);

Контекст содержит:

Свойство Назначение
path Имя поля
parent Родительский объект
options Опции валидации
createError() Создание кастомной ошибки
schema Текущая схема

Доступ к другим полям формы

Одна из самых важных возможностей — доступ к родительскому объекту.

const schema = yup.object({
  password: yup.string().required(),

  confirmPassword: yup.string().test(
    'password-match',
    'Пароли не совпадают',
    function(value) {
      return value === this.parent.password;
    }
  )
});

Кастомные сообщения через createError

Метод createError() позволяет динамически формировать сообщение.

const schema = yup.string().test(
  'min-words',
  'Ошибка',
  function(value) {

    const words = value.trim().split(' ');

    if (words.length < 3) {
      return this.createError({
        message: `Минимум слов: 3. Сейчас: ${words.length}`
      });
    }

    return true;
  }
);

Проверка сложности пароля

Практический пример.

const passwordSchema = yup.string().test(
  'strong-password',
  'Слабый пароль',
  function(value) {

    if (!value) return true;

    const hasUpperCase = /[A-Z]/.test(value);
    const hasLowerCase = /[a-z]/.test(value);
    const hasDigit = /\d/.test(value);
    const hasSpecial = /[@$!%*?&]/.test(value);

    if (!hasUpperCase) {
      return this.createError({
        message: 'Пароль должен содержать заглавную букву'
      });
    }

    if (!hasLowerCase) {
      return this.createError({
        message: 'Пароль должен содержать строчную букву'
      });
    }

    if (!hasDigit) {
      return this.createError({
        message: 'Пароль должен содержать цифру'
      });
    }

    if (!hasSpecial) {
      return this.createError({
        message: 'Пароль должен содержать спецсимвол'
      });
    }

    return true;
  }
);

Асинхронные проверки

Yup поддерживает асинхронную валидацию.

Например, проверка уникальности email.

const schema = yup.string().test(
  'email-exists',
  'Email уже используется',
  async function(value) {

    const response = await fetch('/api/check-email', {
      method: 'POST',
      body: JSON.stringify({ email: value })
    });

    const data = await response.json();

    return !data.exists;
  }
);

Асинхронные ошибки

Асинхронная функция может возвращать createError().

const schema = yup.string().test(
  'blocked-email',
  'Ошибка',
  async function(value) {

    const blocked = ['admin@mail.com'];

    if (blocked.includes(value)) {
      return this.createError({
        message: 'Email заблокирован'
      });
    }

    return true;
  }
);

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

Метод test() позволяет передавать параметры через объект.

const schema = yup.string().test({
  name: 'min-length',
  message: 'Минимальная длина — 10 символов',

  test(value) {
    if (!value) return true;

    return value.length >= 10;
  }
});

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


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

Кастомную проверку можно вынести в отдельную функцию.

const isStrongPassword = value => {

  if (!value) return false;

  return (
    /[A-Z]/.test(value) &&
    /[a-z]/.test(value) &&
    /\d/.test(value)
  );
};

const schema = yup.string().test(
  'strong-password',
  'Слабый пароль',
  isStrongPassword
);

Добавление собственных методов через addMethod

Yup позволяет расширять встроенные типы.

Синтаксис:

yup.addMethod(type, name, method)

Создание собственного метода

Пример метода isPalindrome.

import * as yup from 'yup';

yup.addMethod(yup.string, 'isPalindrome', function(message) {

  return this.test(
    'is-palindrome',
    message,
    function(value) {

      if (!value) return true;

      const reversed = value
        .split('')
        .reverse()
        .join('');

      return value === reversed;
    }
  );
});

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

const schema = yup.string().isPalindrome(
  'Строка не является палиндромом'
);

Использование параметров в собственных методах

Методы могут принимать аргументы.

yup.addMethod(yup.string, 'minWords', function(min, message) {

  return this.test(
    'min-words',
    message,
    function(value) {

      if (!value) return true;

      const words = value
        .trim()
        .split(/\s+/);

      return words.length >= min;
    }
  );
});

Пример:

const schema = yup.string().minWords(
  5,
  'Минимум 5 слов'
);

Кастомные методы для чисел

yup.addMethod(yup.number, 'positiveInteger', function(message) {

  return this.test(
    'positive-integer',
    message,
    value => {

      if (value === undefined) return true;

      return Number.isInteger(value) && value > 0;
    }
  );
});

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

const schema = yup.number().positiveInteger(
  'Только положительные целые числа'
);

Кастомные методы для массивов

yup.addMethod(yup.array, 'unique', function(message) {

  return this.test(
    'unique',
    message,
    function(value) {

      if (!value) return true;

      return new Set(value).size === value.length;
    }
  );
});

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

const schema = yup.array().unique(
  'Массив содержит дубликаты'
);

Типизация кастомных методов в TypeScript

Без расширения типов TypeScript не знает о новых методах.

Необходимо расширить интерфейс.

declare module 'yup' {
  interface StringSchema {
    isPalindrome(message: string): StringSchema;
  }
}

После этого IDE начнёт распознавать метод.


Полноценный пример с TypeScript

import * as yup from 'yup';

declare module 'yup' {
  interface StringSchema {
    strongPassword(message: string): StringSchema;
  }
}

yup.addMethod(
  yup.string,
  'strongPassword',
  function(message: string) {

    return this.test(
      'strong-password',
      message,
      function(value?: string) {

        if (!value) return true;

        const valid =
          /[A-Z]/.test(value) &&
          /[a-z]/.test(value) &&
          /\d/.test(value);

        return valid;
      }
    );
  }
);

const schema = yup.object({
  password: yup
    .string()
    .required()
    .strongPassword('Слабый пароль')
});

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

По умолчанию проверки с одинаковым именем могут накапливаться.

yup.string()
  .test('rule', 'Ошибка 1', () => true)
  .test('rule', 'Ошибка 2', () => true);

Для замены предыдущего теста используется exclusive.

yup.string().test({
  name: 'rule',
  exclusive: true,
  message: 'Ошибка',
  test: () => true
});

Использование контекста валидации

В validate() можно передавать внешний контекст.

const schema = yup.string().test(
  'role-check',
  'Недостаточно прав',
  function(value) {

    return this.options.context.role === 'admin';
  }
);

Проверка:

schema.validate('test', {
  context: {
    role: 'admin'
  }
});

Валидация по условию

Пример динамической проверки.

const schema = yup.object({
  type: yup.string().required(),

  value: yup.string().test(
    'dynamic-validation',
    'Некорректное значение',
    function(value) {

      const { type } = this.parent;

      if (type === 'email') {
        return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
      }

      if (type === 'phone') {
        return /^\+7\d{10}$/.test(value);
      }

      return true;
    }
  )
});

Создание библиотеки валидаторов

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

Структура:

validators/
├── stringValidators.js
├── numberValidators.js
├── arrayValidators.js
└── index.js

Пример:

// validators/stringValidators.js

import * as yup from 'yup';

export const registerStringValidators = () => {

  yup.addMethod(
    yup.string,
    'slug',
    function(message) {

      return this.test(
        'slug',
        message,
        value => {

          if (!value) return true;

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

Регистрация:

import { registerStringValidators } from './validators/stringValidators';

registerStringValidators();

Типичные ошибки

Использование стрелочных функций вместе с this

Неправильно:

test: (value) => {
  return this.parent;
}

У стрелочных функций отсутствует собственный this.

Правильно:

test: function(value) {
  return this.parent;
}

Отсутствие проверки undefined

Неправильно:

value.length > 5

Правильно:

if (!value) return true;

Слишком сложные проверки

Плохая практика:

test(value) {
  // 300 строк логики
}

Лучше:

test(value) {
  return validateBusinessRule(value);
}

Комбинирование кастомных методов

Методы можно объединять.

const schema = yup.string()
  .required()
  .min(8)
  .strongPassword('Слабый пароль')
  .minWords(2, 'Минимум 2 слова');

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

const passwordSchema = yup
  .string()
  .required()
  .strongPassword('Слабый пароль');

const userSchema = yup.object({
  password: passwordSchema
});

const adminSchema = yup.object({
  password: passwordSchema
});

Интеграция с React Hook Form

YupResolver используется через @hookform/resolvers/yup.

import { yupResolver } from '@hookform/resolvers/yup';

const form = useForm({
  resolver: yupResolver(schema)
});

Кастомные методы работают автоматически.

const schema = yup.object({
  password: yup
    .string()
    .strongPassword('Слабый пароль')
});

Ошибки попадают в formState.errors.

errors.password.message

Валидация массивов объектов

yup.addMethod(yup.array, 'uniqueEmails', function(message) {

  return this.test(
    'unique-emails',
    message,
    function(value) {

      if (!value) return true;

      const emails = value.map(item => item.email);

      return new Set(emails).size === emails.length;
    }
  );
});

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

const schema = yup.object({
  users: yup.array().uniqueEmails(
    'Email должны быть уникальными'
  )
});

Кастомная валидация даты

yup.addMethod(yup.date, 'notWeekend', function(message) {

  return this.test(
    'not-weekend',
    message,
    function(value) {

      if (!value) return true;

      const day = value.getDay();

      return day !== 0 && day !== 6;
    }
  );
});

Композиция валидаторов

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

Плохо:

strongValidation()

Лучше:

yup.string()
  .hasUppercase()
  .hasLowercase()
  .hasNumber()
  .hasSpecialCharacter();

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

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

Тестирование кастомных методов

Пример проверки через Jest.

describe('strongPassword', () => {

  test('валидный пароль', async () => {

    const schema = yup
      .string()
      .strongPassword('Ошибка');

    await expect(
      schema.isValid('Admin123')
    ).resolves.toBe(true);
  });

  test('невалидный пароль', async () => {

    const schema = yup
      .string()
      .strongPassword('Ошибка');

    await expect(
      schema.isValid('123')
    ).resolves.toBe(false);
  });
});

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

Тяжёлые проверки могут замедлять формы.

Особенно опасны:

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

Оптимизации:

  • debounce асинхронных проверок;
  • кеширование результатов;
  • ранний выход из проверки;
  • разделение синхронной и асинхронной логики.

Архитектурные рекомендации

Выносить бизнес-логику отдельно

Плохо:

test(function(value) {
  // бизнес-логика
})

Лучше:

test(value => validateTaxNumber(value))

Давать понятные имена тестам

Плохо:

test('x1', ...)

Хорошо:

test('valid-tax-number', ...)

Использовать единый стиль сообщений

Плохо:

'Ошибка'
'Неверно'
'Введите правильно'

Хорошо:

'Неверный формат email'
'Неверный ИИН'
'Неверный номер телефона'

Полный пример комплексной схемы

import * as yup from 'yup';

yup.addMethod(yup.string, 'username', function(message) {

  return this.test(
    'username',
    message,
    function(value) {

      if (!value) return true;

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

yup.addMethod(yup.string, 'strongPassword', function(message) {

  return this.test(
    'strong-password',
    message,
    function(value) {

      if (!value) return true;

      return (
        /[A-Z]/.test(value) &&
        /[a-z]/.test(value) &&
        /\d/.test(value)
      );
    }
  );
});

const registrationSchema = yup.object({

  username: yup
    .string()
    .required('Введите логин')
    .min(4, 'Минимум 4 символа')
    .username('Допустимы только буквы, цифры и _'),

  password: yup
    .string()
    .required('Введите пароль')
    .min(8, 'Минимум 8 символов')
    .strongPassword('Пароль слишком простой'),

  confirmPassword: yup
    .string()
    .required('Подтвердите пароль')
    .test(
      'password-match',
      'Пароли не совпадают',
      function(value) {

        return value === this.parent.password;
      }
    )
});