Пользовательские правила

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

Пользовательские правила строятся вокруг функции enforce.extend(), которая добавляет новые методы в систему enforce.


Архитектура пользовательских правил

Механизм enforce представляет собой цепочку валидаторов:

enforce(value).isNotEmpty().longerThan(3);

После расширения API появляется возможность создавать собственные методы:

enforce.extend({
  isEven(value) {
    return value % 2 === 0;
  }
});

Теперь правило становится частью общей цепочки:

enforce(10).isEven();

Создание простого пользовательского правила

Базовая структура

import { enforce } from 'vest';

enforce.extend({
  isPositive(value) {
    return value > 0;
  }
});

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

test('amount', 'Число должно быть положительным', () => {
  enforce(amount).isPositive();
});

Возвращаемые значения

Пользовательское правило должно возвращать:

  • true — проверка успешна
  • false — проверка не пройдена

Пример:

enforce.extend({
  startsWithA(value) {
    return value.startsWith('A');
  }
});

Правила с аргументами

Часто валидатору требуется дополнительный параметр.

Минимальная длина массива

enforce.extend({
  minItems(value, min) {
    return value.length >= min;
  }
});

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

enforce(tags).minItems(3);

Несколько аргументов

enforce.extend({
  inRange(value, min, max) {
    return value >= min && value <= max;
  }
});

Применение:

enforce(age).inRange(18, 60);

Работа со строками

Проверка наличия спецсимвола

enforce.extend({
  hasSpecialCharacter(value) {
    return /[!@#$%^&*]/.test(value);
  }
});

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

enforce(password).hasSpecialCharacter();

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

Один из наиболее популярных сценариев пользовательской валидации.

enforce.extend({
  strongPassword(value) {
    return (
      /[A-Z]/.test(value) &&
      /[a-z]/.test(value) &&
      /[0-9]/.test(value) &&
      value.length >= 8
    );
  }
});

Пример:

test('password', 'Слабый пароль', () => {
  enforce(password).strongPassword();
});

Использование внешних функций

Пользовательское правило может использовать любые утилиты.

import isEmail from 'validator/lib/isEmail';

enforce.extend({
  corporateEmail(value) {
    return (
      isEmail(value) &&
      value.endsWith('@company.com')
    );
  }
});

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

Внутри пользовательского правила можно использовать существующие методы enforce.

enforce.extend({
  validUsername(value) {
    enforce(value)
      .longerThan(3)
      .shorterThan(20);

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

Генерация сложной бизнес-логики

Проверка налогового номера

enforce.extend({
  validTaxNumber(value) {
    if (!/^\d{12}$/.test(value)) {
      return false;
    }

    const digits = value.split('').map(Number);

    const checksum =
      digits.slice(0, 11).reduce((acc, num) => acc + num, 0) % 10;

    return checksum === digits[11];
  }
});

Правила для массивов

Проверка уникальности элементов

enforce.extend({
  uniqueItems(value) {
    return new Set(value).size === value.length;
  }
});

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

enforce(selectedIds).uniqueItems();

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

Проверка обязательных полей объекта

enforce.extend({
  hasRequiredFields(value, fields) {
    return fields.every(field => {
      return value[field] !== undefined;
    });
  }
});

Пример:

enforce(user).hasRequiredFields([
  'name',
  'email',
  'role'
]);

Асинхронные пользовательские правила

Vest поддерживает асинхронные проверки через async и Promise.

Проверка существования пользователя

enforce.extend({
  async usernameAvailable(value) {
    const response = await fetch(`/api/users/${value}`);

    const data = await response.json();

    return !data.exists;
  }
});

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

test(
  'username',
  'Имя пользователя уже занято',
  async () => {
    await enforce(username).usernameAvailable();
  }
);

Работа с API

Асинхронные правила особенно полезны при интеграции с сервером.

Проверка промокода

enforce.extend({
  async validPromoCode(value) {
    const response = await fetch(
      `/api/promo/${value}`
    );

    const data = await response.json();

    return data.valid;
  }
});

Комбинация синхронной и асинхронной логики

enforce.extend({
  async secureUsername(value) {
    if (value.length < 5) {
      return false;
    }

    const response = await fetch(
      `/api/check/${value}`
    );

    const data = await response.json();

    return data.available;
  }
});

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

Лучше выносить правила в отдельный модуль.

validators.js

import { enforce } from 'vest';

enforce.extend({
  isAdult(value) {
    return value >= 18;
  },

  validPhone(value) {
    return /^\+7\d{10}$/.test(value);
  },

  containsOnlyLetters(value) {
    return /^[a-zа-я]+$/i.test(value);
  }
});

Подключение:

import './validators';

Группировка корпоративных валидаторов

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

auth.validators.js

enforce.extend({
  securePassword(value) {
    return (
      value.length >= 10 &&
      /[A-Z]/.test(value) &&
      /\d/.test(value)
    );
  }
});

finance.validators.js

enforce.extend({
  validCurrency(value) {
    return ['USD', 'EUR', 'KZT'].includes(value);
  }
});

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

Валидатор диапазона

function createRangeValidator(min, max) {
  return value => value >= min && value <= max;
}

enforce.extend({
  validScore: createRangeValidator(0, 100)
});

Пользовательские правила и TypeScript

Vest поддерживает расширение типов.

Расширение интерфейса

declare module 'vest' {
  interface EnforceCustomMatchers<R = void> {
    isPrime(): R;
  }
}

Реализация

enforce.extend({
  isPrime(value: number) {
    if (value <= 1) {
      return false;
    }

    for (let i = 2; i < value; i++) {
      if (value % i === 0) {
        return false;
      }
    }

    return true;
  }
});

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

enforce(17).isPrime();

Проверка зависимых полей

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

enforce.extend({
  matches(value, compareValue) {
    return value === compareValue;
  }
});

Пример:

test('confirmPassword', 'Пароли не совпадают', () => {
  enforce(confirmPassword).matches(password);
});

Использование контекста приложения

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

const forbiddenDomains = [
  'spam.com',
  'fake.com'
];

enforce.extend({
  allowedDomain(value) {
    const domain = value.split('@')[1];

    return !forbiddenDomains.includes(domain);
  }
});

Создание декларативных DSL-правил

Vest позволяет строить собственный DSL поверх enforce.

enforce.extend({
  productCode(value) {
    return /^[A-Z]{3}-\d{4}$/.test(value);
  }
});

Использование становится декларативным:

enforce(code).productCode();

Проверка дат

Валидация будущей даты

enforce.extend({
  futureDate(value) {
    return new Date(value) > new Date();
  }
});

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

enforce.extend({
  adultBirthday(value) {
    const birth = new Date(value);

    const now = new Date();

    const age =
      now.getFullYear() - birth.getFullYear();

    return age >= 18;
  }
});

Работа с регулярными выражениями

Универсальное regex-правило

enforce.extend({
  matchesRegex(value, pattern) {
    return pattern.test(value);
  }
});

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

enforce(phone).matchesRegex(/^\+7\d{10}$/);

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

enforce.extend({
  requiredIf(value, condition) {
    if (!condition) {
      return true;
    }

    return value !== '';
  }
});

Пример:

enforce(companyName).requiredIf(isBusiness);

Ошибки внутри пользовательских правил

Нежелательно выбрасывать исключения без необходимости.

Плохой подход

enforce.extend({
  unsafe(value) {
    return value.name.length > 3;
  }
});

Если value.name отсутствует:

TypeError

Безопасный подход

enforce.extend({
  safe(value) {
    return value?.name?.length > 3;
  }
});

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

Некоторые рекомендации:

Не выполнять тяжёлые вычисления

Плохо:

enforce.extend({
  hugeCalculation(value) {
    return expensiveOperation(value);
  }
});

Лучше:

const cache = new Map();

enforce.extend({
  optimizedRule(value) {
    if (cache.has(value)) {
      return cache.get(value);
    }

    const result = expensiveOperation(value);

    cache.set(value, result);

    return result;
  }
});

Изоляция побочных эффектов

Пользовательские правила должны быть максимально чистыми.

Нежелательно:

enforce.extend({
  trackValidation(value) {
    analytics.send(value);

    return true;
  }
});

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


Тестирование пользовательских правил

Unit-тестирование

describe('isPositive', () => {
  test('валидное число', () => {
    expect(
      enforce(10).isPositive()
    ).toBeTruthy();
  });

  test('невалидное число', () => {
    expect(
      enforce(-5).isPositive()
    ).toBeFalsy();
  });
});

Проверка асинхронных валидаторов

test('usernameAvailable', async () => {
  await expect(
    enforce('admin')
      .usernameAvailable()
  ).resolves.toBeTruthy();
});

Частые ошибки

Возврат не boolean-значений

Плохо:

enforce.extend({
  invalid(value) {
    return value;
  }
});

Правильно:

enforce.extend({
  valid(value) {
    return Boolean(value);
  }
});

Конфликт имён правил

Если имя совпадает со встроенным методом, пользовательское правило может переопределить стандартное поведение.

enforce.extend({
  equals() {
    return true;
  }
});

Такое переопределение может привести к трудноуловимым ошибкам.


Масштабирование системы правил

В больших приложениях обычно применяются:

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

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

import { enforce } from 'vest';

enforce.extend({
  strongPassword(value) {
    return (
      value.length >= 8 &&
      /[A-Z]/.test(value) &&
      /\d/.test(value)
    );
  },

  validPhone(value) {
    return /^\+7\d{10}$/.test(value);
  },

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

  futureDate(value) {
    return new Date(value) > new Date();
  },

  matches(value, compareValue) {
    return value === compareValue;
  }
});

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

test('password', 'Слабый пароль', () => {
  enforce(password).strongPassword();
});

test('phone', 'Некорректный номер', () => {
  enforce(phone).validPhone();
});

test('eventDate', 'Дата должна быть будущей', () => {
  enforce(eventDate).futureDate();
});