Декларативная валидация данных с помощью функционального программирования и rule-based подхода

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

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

Для иллюстрации подхода я приготовил приложение-пример — «форму заявки на колонизацию Марса».

В форме 8 полей с разными типами данных, мы будем проверять каждое и выводить ошибку, если значение поля невалидно
В форме 8 полей с разными типами данных, мы будем проверять каждое и выводить ошибку, если значение поля невалидно

Пример простой, но я постарался собрать в нём частые примеры разных типов данных (телефон, почта, численное значение, дата), проверку пароля и взаимосвязанные поля. Само приложение и его исходный код можно потыкать по ссылкам:

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

Основная проблема с проверкой данных на клиенте в слишком тесном переплетении правил, по которым мы проверяем данные, и особенностей пользовательского интерфейса.

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

У разных проектов требования к валидации могут отличаться. Например, валидация может быть реактивной — чтобы форма проверялась по мере её заполнения. Иногда — наоборот, требуется, чтобы форма проверялась только после её полного заполнения. Или чтобы форма показывала дополнительные поля после ввода какого-то значения.

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

Доменная, интерфейсная и инфраструктурная логика

Доменной логикой мы будем называть правила валидации, которые продиктованы бизнес-требованиями. У каждого такого правила есть какая-то причина в реальном мире, который мы моделируем в программе.

Например, если телефон или почта будут указаны неверно, с пользователем не смогут связаться. Это обстоятельство и есть причина для проверки телефона или почты.

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

Примером может быть исчезание и появление взаимозависимых полей. В реальном мире нет причины прятать какие-то поля — в бумажных формах поля не прячут, а объясняют текстом, какое поле при каких условиях заполнить. Но на экране мы хотим (и можем) упростить пользователю жизнь и поэтому адаптируем интерфейс, пряча и показывая нужные поля.

Инфраструктурной логикой будем называть то, что непосредственно прогоняет данные через правила. О ней можно думать, как о «сервисе валидации», которому мы скармливаем правила и данные, а он делает вывод, валидны ли они. Такую логику мы разберём подробнее позже, когда доберёмся до примера. Сейчас же поговорим о самом главном в проверке данных — правилах валидации.

Сила композиции

Отбросим всё лишнее и посмотрим на скелет валидации. В основе лежит проверка значения на соответствие каким-то критериям.

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

Критерии как функции

Чистые функции — это функции, которые не производят побочных эффектов и всегда выдают одинаковый результат при одинаковых входных данных. Если такая функция возвращает булево значение, мы можем назвать её предикатом:

const isGreaterThan5 = (value) => value > 5;

Предикаты по смыслу похожи на правила валидации: они принимают значение и отвечают, «подходит» значение или «не подходит». Такие функции предсказуемы, тестируемы и декларативны — то есть описывают результат, который мы хотим получить.

Чистыми функциями удобно описывать проверки, потому что мы можем прямо в названии указать проверяемый критерий. Если мы уверены, что такие функции точно возвращают булево значение — нам не нужно будет смотреть их код, чтобы понять, как они работают.

Например, если при валидации строки мы проверяем, что она содержит точку и длиной не меньше 10 символов, то в коде мы можем это выразить двумя такими функциями:

const containsPointCharacter = (str) => str.includes('.');
const longerOrEqualThan10 = (str) => str.length >= 10;

Каждая отдельная функция проверяет один критерий, одну «фичу» переданного значения. Если мы хотим проверить оба критерия одновременно, то можем вызвать обе функции и проверить, что обе функции вернули истину:

const value = 'lol.kek.cheburek';
containsPointCharacter(value) && longerOrEqualThan10(value);
// true

Либо же — написать функцию, которая объединит в себе функциональность этих двух и проверит значение на соответствие обоим критериям:

const isValid = (value) => containsPointCharacter(value) && longerOrEqualThan10(value);

Таким образом мы можем собирать более сложные правила из простых — компоновать их.

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

В общем смысле композиция — это составление сложных штук из штук попроще. Здесь мы составляем большие (сложные) правила проверки из маленьких (простых).

Чем проще и интуитивнее механизм для составления сложных правил, тем меньше ошибок мы будем допускать при их создании. При этом любое сложное правило мы сможем свести к набору простых, используя двоичную логику. Например, мы можем использовать операцию AND && для проверки всех критериев одновременно и OR || для проверки хотя бы одного.

Дублирование и переиспользование кода

Такой подход к компоновке правил развязывает руки. Так как каждая функция проверяет один критерий, одну «фичу», некоторые будут достаточно абстрактны, чтобы быть частью нескольких правил сразу.

Например, мы можем использовать функцию isString как в проверке телефона, так и в проверке почты:

const isString = (x) => typeof x === 'string';

Если в правилах валидации мы найдём повторяющиеся критерии, мы сможем переиспользовать уже написанные функции для композиции новых правил и сократить дублирование.

Далее мы увидим на примерах, что на этом борьба с дублированием не заканчивается. Мы рассмотрим, как можно выносить шаблонные действия в «надпрограммы» и «надструктуры». Всё по заветам СИКП 😃

Пример приложения

Перейдём от теории к практике и напишем приложение-пример — валидацию для заявки на колонизацию Марса — с нуля. Я не буду приводить код разметки, стилей и большей части работы с DOM, потому что он не так важен для этой темы. Но исходники всегда можно посмотреть на Гитхабе или подглядеть в инспекторе у работающего приложения.

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

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

Реализация в коде также будет намеренно простой. Предлагаю вам самостоятельно поиграться с репозиторием или даже написать всё самим и поэкспериментировать с кодом, чтобы понять, насколько вам нравится или не нравится этот подход. Ну а теперь — за дело.

Определяем правила

Начнём с «ядра» проверки — с правил. Условимся, что мы уже узнали все требования бизнеса и записали их. Допустим требования такие:

  • телефон и почта должны быть в корректном формате;
  • телефон должен начинаться с «+», то есть быть международным;
  • пользователь должен быть не моложе 20 и не старше 50 лет;
  • пользователю следует выбрать специальность из предложенного списка;
  • если специальности в списке нет, то указать самостоятельно, длина строки в этом случае не должна быть больше 50 символов;
  • опыт работы должен быть не меньше 3 лет;
  • кодовое слово должно быть не короче 10 символов, иметь как минимум одну прописную букву и как минимум одну цифру.

Все эти правила — часть домена, потому что для существования каждого из них есть причина в реальном мире. Телефон и почта нужны, чтобы связаться с пользователем. Возраст от 20 до 50 — чтобы колонизаторы лучше пережили перегрузки на борту и радиацию на новой планете. Указанные в списке специальности имеют больший приоритет, потому что колонизаторам больше всего нужны биологи, инженеры и психологи и т. д.

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

Моделируем тип формы

В примерах кода я буду использовать TypeScript. Большая часть примеров почти не будет отличаться от кода на JavaScript, но если вы всё же чувствуете себя не уверенно, рекомендую прочесть TypeScript Definitive Guide.

Итак, данные из формы мы представим в виде типа ApplicationForm. Каждое поле этого типа будет полем в самой форме. Типы полей мы представим в виде типов-обёрток, чтобы избежать одержимости примитивами.

// types.ts

export type ApplicationForm = {
	name: ApplicantName;
	phone: PhoneNumber;
	email: EmailAddress;
	birthDate: BirthDate;
	photo: UserPhoto;

	specialty: KnownSpecialty;
	customSpecialty: UnknownSpecialty;
	experience: ExperienceYears;

	password: Password;
};

Спроектируем типы обёртки для полей:

// types.ts

type ApplicantName = string;
type PhoneNumber = string;
type EmailAddress = string;
type BirthDate = DateString;
type UserPhoto = Image;

type KnownSpecialty = 'engineer' | 'scientist' | 'psychologist';
type UnknownSpecialty = string;
type ExperienceYears = NumberLike;

type Password = string;

Типы NumberLike, DateString и Image достаточно абстрактны, чтобы вынести их в отдельный модуль. Создадим глобально доступные аннотации в shared-kernel.d.ts и добавим эти типы туда. Кроме них, добавим несколько вспомогательных типов, которые нам пригодятся в будущем:

// shared-kernel.d.ts

// Хелперы для опциональных значений:
type Nullable<T> = T | null;
type Optional<T> = T | undefined;

// Обёртка над массивом:
type List<T> = T[];

// Так как инпуты возвращают строки, нам потребуется тип,
// который выразит _намерение_ получить число из строки:
type NumberLike = string;

type Comparable = string | number;

// Улучшим читаемость кода и добавим деталей о реальности:
type DateString = string;
type TimeStamp = number;
type NumberYears = number;

type LocalFile = File;
type Image = LocalFile;

Мы описали форму типом ApplicationForm. Этот тип мы будем использовать в правилах проверки формы, как сигнатуру входных данных.

Реализуем критерии проверки

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

Все правила будут принимать на вход объект формы и возвращать булево значение. То есть они будут предоставлять одинаковое «публичное API», которое мы можем представить в виде сигнатуры:

ApplicationForm => boolean

Вся остальная логика приложения будет зависеть от этих правил, не наоборот. Так мы изолируем логику проверки и отцепим её от остального кода.

Начнём с имени, по требованиям оно просто должно быть. Дополнительных ограничений нет, поэтому проверим значение на истинность:

// validation.ts

export const validateName = ({ name }) => !!name;

Заметим, что функция принимает на вход объект формы, из которого получает имя и проверяет его истинность. Однако, проверка на истинность — довольно частая операция. Мы можем вынести такую проверку в отдельную функцию, чтобы переиспользовать её в будущем:

// utils.ts

export const exists = <TEntity>(x: TEntity) => !!x;

// validation.ts

export const validateName = ({ name }) => exists(name);

Так мы разделяем уровни абстракции. Повторяющуюся операцию — проверку примитива — мы держим в функции exists. Поэтому далее при проверке имени мы уже не углубляемся в работу с примитивом, а используем exists, которая берёт эту работу на себя.

Абстрагирование позволяет нам «убрать лишние детали». При проверке имени нам не важно, как именно мы проверим его существование. Мы «заметаем» детали проверки в отдельную функцию и полагаемся уже на неё в целом — как на единичное действие. Это делает код переиспользуемым и читаемым.

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

Перейдём к почте. Допустим, наше правило проверки почты состоит из двух критериев: «строка должна содержать @» и «строка должна содержать точку». Такие критерии мы можем скомпоновать через AND:

// validation.ts

export const validateEmail = ({ email }) => email.includes('@') && email.includes('.');

С телефоном будет интереснее. Там тоже есть два критерия: «международный формат» и «только разрешённые символы». Мы можем это записать вот так:

// validation.ts

const validatePhone = ({ phone }) => phone.startsWith('+') && phone.search(/[^ds-()+]/g) < 0;

…Но такой код слегка пахнет, потому что сложно понять, почему здесь именно «+» и почему мы ищем именно такой паттерн. Вместо этого мы можем разбить «фичи» на функции, а в их именах декларировать намерение:

// validation.ts

const onlyInternational = ({ phone }) => phone.startsWith('+');
const onlySafeCharacters = ({ phone }) => phone.search(/[^ds-()+]/g) < 0;

Теперь можно заметить, что в функции onlySafeCharacters есть ещё одна операция, которая нам пригодится в будущем — поиск по строке. Вынесем эту операцию в функцию и тоже понятно назовём:

// utils.ts

export const contains = (value: string, pattern: RegExp) => value.search(pattern) >= 0;

// validation.ts

const onlySafeCharacters = ({ phone }) => !contains(phone, /[^ds-()+]/g);

Для проверки даты рождения используем критерии «дата в виде строки с валидным форматом» и «возраст пользователя от 20 до 50 лет».

// utils.ts

export const inRange = (value: Comparable, min: Comparable, max: Comparable) =>
	value >= min && value <= max;

export const yearsOf = (date: TimeStamp): NumberYears =>
	new Date().getFullYear() - new Date(date).getFullYear();

// validation.ts

const MIN_AGE = 20;
const MAX_AGE = 50;

const validDate = ({ birthDate }) => !Number.isNaN(Date.parse(birthDate));
const allowedAge = ({ birthDate }) => inRange(yearsOf(Date.parse(birthDate)), MIN_AGE, MAX_AGE);

Проверка специальности ссылается на разные взаимозависимые поля: если пользователь выбрал профессию из списка — используем её, а если нет — используем дополнительное поле и проверяем, что длина его значения не больше 50 символов.

// validation.ts

const MAX_SPECIALTY_LENGTH = 50;
const DEFAULT_SPECIALTIES: List<KnownSpecialty> = ['engineer', 'scientist', 'psychologist'];

const isKnownSpecialty = ({ specialty }) => DEFAULT_SPECIALTIES.includes(specialty);

const isValidCustom = ({ customSpecialty: custom }) =>
	exists(custom) && custom.length <= MAX_SPECIALTY_LENGTH;

Как мы видим, у нас нет проблем с взаимозависимыми полями. У обеих функций достаточно данных и контекста для проверки таких полей, потому что мы передаём на вход весь объект формы, а не значения полей по отдельности.

Мы можем думать об этом объекте, как о единице передачи информации. Мы как бы упаковываем в него всё, что может потребоваться для проверки формы, учитывая взаимозависимые поля — такой объект получается самодостаточным. Главное, следить, чтобы данные внутри относились к одной задаче, а уровень абстракции был одинаковым у всех полей.

Дальше проверяем опыт. Правила требуют, чтобы опыт колонистов в своей области был не менее 3 лет. Так и напишем, но не забудем, что инпуты возвращают строки, и конвертируем значение в число:

const isNumberLike = ({ experience }) => Number.isFinite(Number(experience));
const isExperienced = ({ experience }) => Number(experience) >= MIN_EXPERIENCE_YEARS;

Последним проверяем пароль. Он должен быть не короче 10 символов, содержать хотя бы одну прописную букву и хотя бы одну цифру:

const atLeastOneCapital = /[A-Z]/g;
const atLeastOneDigit = /d/gi;

const hasRequiredSize = ({ password }) => password.length >= MIN_PASSWORD_SIZE;
const hasCapital = ({ password }) => contains(password, atLeastOneCapital);
const hasDigit = ({ password }) => contains(password, atLeastOneDigit);

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

Именно для этого мы выделяем более низкоуровневые операции в функции, давая им понятные имена — так мы делаем намерения понятнее.

Компонуем правила валидации

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

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

const validatePassword = (form: ApplicationForm) =>
	hasRequiredSize(form) && hasCapital(form) && hasDigit(form);

Легко заметить, что подобная компоновка будет повторяться и в других случаях:

const validateBirthDate = (form: ApplicationForm) => validDate(form) && allowedAge(form);
const validateExperience = (form: ApplicationForm) => isNumber(form) && isExperienced(form);

// …

Но мы можем вынести эту операцию — сочетание разных критериев — в функцию! Тогда мы не будем вызывать функции-критерии руками и передавать им аргументы. Мы сможем это автоматизировать и сделать намерение более явным. Напишем функции, которые компонуют критерии в правила:

// services/validation.ts

export function all(rules) {
	return (data) => rules.every((isValid) => isValid(data));
}

export function some(rules) {
	return (data) => rules.some((isValid) => isValid(data));
}

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

Чтобы доказать, что такие компоновщики могут работать с любыми правилами, добавим сигнатуры типов — мы увидим, что эти функции можно сделать дженериками:

// services/validation.ts

export type ValidationRule<T> = (data: T) => boolean;

type RequiresAll<T> = ValidationRule<T>;
type RequiresAny<T> = ValidationRule<T>;

export function all<T>(rules: List<ValidationRule<T>>): RequiresAll<T> {
	return (data) => rules.every((isValid) => isValid(data));
}

export function some<T>(rules: List<ValidationRule<T>>): RequiresAny<T> {
	return (data) => rules.some((isValid) => isValid(data));
}

И теперь мы можем использовать компоновщики, чтобы собрать критерии валидации в правила:

//validation.ts

const phoneRules = [onlyInternational, onlySafeCharacters];
const birthDateRules = [validDate, allowedAge];
const specialtyRules = [isKnownSpecialty, isValidCustom];
const experienceRules = [isNumberLike, isExperienced];
const passwordRules = [hasRequiredSize, hasCapital, hasDigit];

export const validatePhone = all(phoneRules);
export const validateBirthDate = all(birthDateRules);
export const validateSpecialty = some(specialtyRules);
export const validateExperience = all(experienceRules);
export const validatePassword = all(passwordRules);

Эти правила мы уже можем использовать, если, например, хотим проверить конкретное поле. Но мы можем пойти дальше и собрать валидатор для всей формы, используя те же самые компоновщики!

Собираем валидатор для всей формы

У правил такая же сигнатура как у критериев, поэтому мы можем использовать all и some, чтобы составлять правила в ещё более сложные правила. Например, для валидации формы из примера мы можем написать:

// validation.ts

export const validateForm = all([
	validateName,
	validateEmail,
	validatePhone,
	validateBirthDate,
	validateSpecialty,
	validateExperience,
	validatePassword
]);

…И функция validateForm будет проверять, что каждое правило (уже не критерий, а правило) выполняется.

Ошибки валидации

Функция validateForm отвечает, валидна ли форма. Но она не может сказать, какое именно поле выдало ошибку и какое правило не сработало. Для пользователя заполнение такой формы будет кошмаром, исправим это.

Проектируем результат валидации

Сперва подумаем, в каком виде мы хотим получать результат. Я подумал, что будет достаточно объекта с двумя полями: valid и errors. Первый будет отвечать на вопрос, валидна ли форма, а второй — будет содержать сообщения об ошибках для каждого невалидного поля.

// services/validation.ts

export type ErrorMessage = string;
export type ErrorMessages<TData> = Partial<Record<keyof TData, ErrorMessage>>;

export type ValidationRules<TData> = Partial<Record<keyof TData, ValidationRule<TData>>>;

type ValidationResult<TData> = {
	valid: boolean;
	errors: ErrorMessages<TData>;
};

Ошибки и правила тогда мы отобразим в виде объектов, где ключами будут поля формы, а значениями — сообщения об ошибках и функции-правила соответственно:

// validation.ts

type ApplicationRules = ValidationRules<ApplicationForm>;
type ApplicationErrors = ErrorMessages<ApplicationForm>;

const rules: ApplicationRules = {
	name: validateName,
	email: validateEmail,
	phone: validatePhone,
	birthDate: validateBirthDate,
	specialty: validateSpecialty,
	experience: validateExperience,
	password: validatePassword
};

const errors: ApplicationErrors = {
	name: 'Your name is required for this mission.',
	email: 'Correct email format is user@example.com.',
	phone: 'Please, use only “+”, “-”, “(”, “)”, and a whitespace.',
	birthDate: 'We require applicants to be between 20 and 50 years.',
	specialty: 'Please, use up to 50 characters to describe your specialty.',
	experience: 'For this mission, we search for experience 3+ years.',
	password: 'Your password must be longer than 10 characters, include a capital letter and a digit.'
};

Опять же ошибки и правила могут быть любыми, поэтому типы ValidationRules и ErrorMessages мы можем сделать дженериками. Самому валидатору структура этих объектов тоже будет не важна — его задачей будет прогнать каждое поле через соответствующее правило и записать результат. А раз так, то мы создадим не один конкретный валидатор, а фабрику.

Создаём фабрику валидаторов

Фабрика — это сущность, которая создаёт другие сущности. В нашем случае — функция, которая будет создавать функции. Мы снова вынесем однотипные действия в «надпрограмму», функцию createValidator:

// services/validation.ts

export function createValidator<TData>(
	rules: ValidationRules<TData>,
	errors: ErrorMessages<TData>
) {
	return function validate(data: TData): ValidationResult<TData> {
		const result: ValidationResult<TData> = {
			valid: true,
			errors: {}
		};

		Object.keys(rules).forEach((key) => {
			// Для каждого из полей находим правило проверки:
			const field = key as keyof TData;
			const validate = rules[field];

			// Если правила нет, пропускаем поле:
			if (!validate) return;

			// Если значение поля невалидно, указываем ошибку:
			if (!validate(data)) {
				result.valid = false;
				result.errors[field] = errors[field];
			}
		});

		return result;
	};
}

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

Использовать такую фабрику мы сможем вот так:

// validation.ts

export const validateForm = createValidator(rules, errors);

// Сигнатура у validateForm будет: ApplicationForm => ValidationResult<ApplicationForm>.
// Благодаря дженерикам компилятор понимает, с какой структурой данных будет работать.

Ну а сам валидатор мы сможем использовать так:

// main.ts

// …
const data: ApplicationForm = Object.fromEntries(new FormData(e.target));
const { valid, errors } = validateForm(data);
// Если !valid, показать пользователю ошибки из errors.
// …

Обратите внимание, что мы держим createValidator (а также all и some) отдельно от непосредственно правил. Эти функции решают утилитарную задачу — компоновку правил и представление результата.

Так мы разделяем доменную и инфраструктурную логику. Теперь, если нам понадобится поменять структуру результата валидации, нам не потребуется менять правила. Нам нужно лишь будет изменить инфраструктурный, «сервисный» код.

Шаблоны, паттерн-матчинг и метапрограммирование

Возможно, кто-то увидел в этом подходе искажённый шаблон «Стратегия», а кто-то «недо-паттерн-матчинг». В целом, вы правы.

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

Кроме возможности генерировать функциональность у правило-ориентированного подхода есть и другие преимущества.

Плюсы правило-ориентированного подхода

Я насчитал 5 штук.

Расширяемость

Добавить новые правила или изменить существующие становится проще. Как минимум всегда понятно, где искать место для обновления. Например, если нам нужно добавить новый критерий для пароля, скажем, чтобы он содержал спецсимвол, то мы добавим новую функцию:

const hasSpecialCharacter = ({ password }) => contains(password, specialCharactersRegex);

…А потом добавим её в список правил для проверки пароля:

const passwordRules = [hasRequiredSize, hasCapital, hasDigit, hasSpecialCharacter];

Если нам надо обновить какую-то функцию, скажем, заменить проверку почты на проверку по регулярному выражению, то мы обновим только функцию validateEmail:

const validateEmail = ({ email }) => emailRegex.test(email);

Если же нам надо добавить в форму новое поле, то мы обновим список правил и ошибок. Пусть мы захотели добавить поле с размером одежды, чтобы правильно пошить форму:

const validateSize = ({size}) => // ...Правило проверки.

const rules = {
  // ...Все прежние правила.
  size: validateSize,
}

const errors = {
  // ...Все прежние сообщения об ошибках.
  size: 'Please, use American size chart.'
}

Ну а если поле из формы нужно убрать, то достаточно найти и удалить связанный с ним код.

Читаемость

Декларативный стиль проще читать, чем императивный. Пиша декларативно проще выделять разные уровни абстракций и объясняться терминами из предметной области. Это позволяет не тратить ресурсы на «парсинг» лишних деталей в голове при чтении кода.

Тестируемость

Чистые функции проще тестировать. Для них не нужно «мокать сервисы» и «наворачивать инфраструктуру», достаточно тест-раннера и тестовых данных. Каждое правило можно протестировать изолированно, а если их много — запустить тесты параллельно.

Сам сервис валидации достаточно проверить один раз. Если мы убедились, что он верно компонует функции и прогоняет значение через все правила, нам не нужно будет проверять это каждый раз.

Живая документация

Правила, описанные функциями с понятными названиями, можно давать на проверку «непрограммистам». (Не всегда, конечно, но в идеале — можно.) Такие правила можно сделать частью общего языка из Domain Driven Design.

Нет зависимостей

Для подобной валидации не нужны сторонние библиотеки. Понятно, что кому-то это может не подойти по разным причинам — это скорее плюс конкретно для меня. Я в целом стараюсь осторожно выбирать зависимости. Если какой-то кусок функциональности я могу написать сам, и это не будет забагованным велосипедом и чёрной дырой в плане ресурсов и времени, то я рассмотрю вариант «написать самому».

Если же библиотека прям нужна, то функции проверки несложно будет с ней подружить. Особенно, если библиотека тоже поддерживает декларативный подход, как, например, React Hook Form.

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

Недостатки

Понятно, что и без недостатков тут не обойдётся. Вот, с чем я столкнулся, пока использовал такие валидаторы.

Нужен контракт для обработки ошибок

Не всегда понятно, что нам стоит возвращать в результате валидации. В примере мы возвращаем сообщения об ошибках для каждого правила, но может быть нужно сообщать об ошибках для каждого критерия или первый символ, где произошла ошибка, или что-то ещё. Это требование может меняться от проекта к проекту.

Можно вынести определение контракта в отдельную функцию или написать более общий валидатор, но тогда код может стать слишком сложным.

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

Нужно продумывать «гранулярность» правил

Не всегда ясно, насколько примитивными должны быть правила. Есть, например, вот такой валидатор, в котором правила тоже декларативные, но работают по большей части с примитивными критериями. Для форм, вероятно, такие правила вполне подойдут, а вот для проверки сериализованных DTO при десериализации могут не подойти.

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

Производительность и конвертеры

В коде примера есть два правила, которые немного пахнут: validateBirthDate и validateExperience. Их функции-критерии конвертируют строки в даты и числа и делают это каждый раз при вызове.

// Например, Date.parse вызывается дважды при проверке одного поля:
const validDate = ({ birthDate }) => !Number.isNaN(Date.parse(birthDate));
const allowedAge = ({ birthDate }) => inRange(yearsOf(Date.parse(birthDate)), MIN_AGE, MAX_AGE);

Сложные структуры могут приводить к просадкам в производительности. По-хорошему, конвертацию стоит делать единожды. (Ну и ещё неплохо бы покрыть типами структуру до конвертации и после.) Нам бы пригодилась функция типа:

type BirthDate = TimeStamp;
type ExperienceYears = YearsNumber;

type ApplicantForm = {
	// ...
	birthDate: BirthDate;
	experience: ExperienceYears;
};

function toApplicantForm(raw: RawApplicantForm): ApplicantForm {
	return {
		...raw,
		birthDate: Date.parse(raw.birthDate),
		experience: Number(experience)
	};
}

Валидатор как нежелательная зависимость

Если отвлечься, то можно случайно затащить сервис валидации как зависимость во все остальные модули. Я стараюсь следить за тем, чтобы доменная логика была чистой и не зависела от сторонних сервисов.

Обычно помогает делить проверку значения и её получение из объекта. На примере проверки почты это могло бы быть так:

// Доменная функция, работает с обёрткой над примитивом:
const isValidEmail = (email: EmailAddress) => email.includes('@') && email.includes('.');

// Функция в прикладном слое, работает со всем объектом формы:
const validateEmail = ({ email }: ApplicationForm) => isValidEmail(email);

Тогда бизнес правила стали бы ещё чище и независимее, но чаще всего это оверхед. Иногда можно пожертвовать «чистотой» ради лаконичности. «Чистые» правила, однако, чуть удобнее использовать при десериализации объектов передачи данных.

Ссылки и ресурсы

Как обычно, в конце я собрал огромный список того, что лежит в основе этой статьи.

Приложение и исходники

Общие термины и понятия

Декларативность и функциональное программирование

Проектирование и архитектура программ

Абстракция и уровни сложности

Книги по теме

Упомянутые библиотеки

Мои статьи в блоге и на других платформах