Я всегда считал валидацию клиентских данных сложной задачей. Самостоятельно написанный код валидаторов легко скатывается в нечитаемый беспорядок, а библиотеки валидации иногда несут инфраструктурные ограничения, которые могут усложнить их интеграцию.
В этой статье я хочу показать вам принцип, который я использую в своих проектах и который упрощает валидацию и помогает писать сопровождаемый и расширяемый код.
Чтобы проиллюстрировать этот подход, я подготовил пример приложения «Форма заявки на колонизацию Марса».
Пример довольно прост, но я постарался собрать частые примеры различных типов данных (телефон, почта, числовое значение, дата), валидации пароля и взаимозависимых полей.
Вы можете найти приложение и его исходный код по ссылкам ниже:
- Образец приложения
- Исходные тексты на GitHub
В этом приложении я намеренно не использовал HTML-атрибуты для валидации формы, чтобы «увеличить объем кода». Так лучше видно преимущества и проблемы этого принципа. В реальных проектах, конечно, лучше, чтобы большую часть работы выполнял браузер.
Этот пример можно рассматривать с точки зрения валидации не конкретной формы, а «просто данных», таких как DTO или ответ сервера.
Проблема с клиентской валидацией
Основная проблема с проверкой данных на клиенте заключается в том, что правила, по которым мы проверяем данные, слишком тесно переплетаются с особенностями пользовательского интерфейса.
Сама валидация данных часто тривиальна, и даже взаимозависимые поля не сильно усложняют задачу. Но то, как мы показываем результаты валидации пользователю и какие события должны быть вызваны во время или после валидации, — это не так.
Разные проекты имеют разные требования к валидации. Например, валидация может быть реактивной — то есть форма проверяется по мере ее заполнения. Иногда, наоборот, форма должна быть проверена после того, как она полностью заполнена. Или форма может показывать дополнительные поля после ввода значения.
При работе с различными требованиями становится трудно отделить логику интерфейса от логики домена, но именно это разделение помогает держать под контролем сложность кода.
Логика домена, пользовательского интерфейса и инфраструктуры
Под доменной логикой мы будем понимать правила проверки, которые диктуются бизнес-требованиями. Каждое из этих правил имеет причину присутствовать в реальном мире.
Например, если номер телефона или электронная почта указаны неверно, мы не сможем связаться с пользователем. Это обстоятельство является причиной для проверки телефона или электронной почты.
Логика пользовательского интерфейса — это то, что пользователь видит на экране. Чаще всего это не имеет ничего общего с реальным миром. Интерфейсная логика отвечает только за изменения интерфейса. Она ничего не знает о самих правилах, но знает, как показать пользователю ошибку или как выделить недопустимое поле.
Примером может служить появление и исчезновение взаимозависимых полей. В реальном мире нет причин скрывать какие-либо поля. В бумажных формах поля не скрывают, а объясняют в тексте, какое поле при каких условиях нужно заполнить. Но на экране мы хотим (и можем) упростить жизнь пользователю, поэтому адаптируем интерфейс, скрывая и показывая нужные поля.
Инфраструктурная логика — это логика, которая непосредственно проводит данные через правила. Мы можем думать о ней как о «службе проверки», которой мы передаем правила и данные. Она проверяет, действительны ли данные. Мы рассмотрим эту логику более подробно, когда дойдем до примера, а пока давайте поговорим о главном — правилах валидации.
Сила композиции
Давайте посмотрим на скелет валидации данных. Она основана на проверке значения по некоторому критерию.
Критерий — это стандарт, по которому мы проверяем значение. В большинстве случаев проверка сводится к сравнению примитивного значения с некоторым «стандартным» значением. Такие проверки лучше всего описывать как чистые функции, которые принимают значение на вход и говорят нам, является ли оно действительным.
Критерии как функции
Чистые функции — это функции, которые не имеют побочных эффектов и всегда дают один и тот же результат при одних и тех же входных данных. Если такая функция возвращает булево значение, мы можем назвать ее предикатом:
const isGreaterThan5 = (value) => value > 5;
Предикаты похожи на правила валидации: они принимают значение и отвечают, является ли это значение «нормальным» или «не нормальным». Такие функции предсказуемы, проверяемы и декларативны — то есть, они описывают результат, который мы хотим получить.
Чистые функции удобны для описания проверок, потому что мы можем указать проверяемый критерий прямо в имени. А поскольку они являются предикатами (т.е. всегда возвращают булево значение), нам не нужно смотреть на их код, чтобы понять, как они работают.
Например, если при проверке строки мы проверяем, что она содержит точку и не короче 10 символов, мы можем выразить это в коде с помощью двух таких функций:
const containsPointCharacter = (str) => str.includes(".");
const longerOrEqualThan10 = (str) => str.length >= 10;
Каждая отдельная функция проверяет один критерий, одну «особенность» переданного значения. Если мы хотим проверить оба критерия одновременно, мы можем вызвать обе функции и проверить, что обе функции вернули true:
const value = "lol.kek.cheburek";
containsPointCharacter(value) && longerOrEqualThan10(value);
// true
Или написать функцию, которая объединяет функциональность этих двух функций и проверяет значение по обоим критериям:
const isValid = (value) =>
containsPointCharacter(value) && longerOrEqualThan10(value);
Таким образом, мы можем собирать более сложные правила из более простых — составлять их.
Композиция правил
В общем смысле композиция — это создание сложных вещей из более простых. Здесь мы составляем большие (сложные) правила проверки из маленьких (простых).
Чем проще и интуитивнее механизм создания сложных правил, тем меньше ошибок мы допустим при их описании. Мы можем свести любое сложное правило к набору простых, используя двоичную логику. Например, мы можем использовать операцию AND &&
для проверки всех критериев сразу и OR ||
для проверки хотя бы одного.
Это похоже на алгебраическую систему типов — где мы можем создавать сложные типы и простые типы, используя операции AND и OR.
Дублирование и многократно используемый код
Поскольку каждая функция проверяет один критерий, одну «особенность», они достаточно абстрактны, чтобы быть частью нескольких правил одновременно.
Например, мы можем использовать функцию isString
как в проверке телефона, так и почты:
const isString = (x) => typeof x === "string";
Если мы находим одинаковые критерии в правилах проверки, мы можем повторно использовать уже написанные функции, чтобы уменьшить дублирование.
Далее мы увидим на примерах, что борьба с дублированием на этом не заканчивается. Мы рассмотрим, как можно вынести шаблонные действия в «надпрограммы». Все в соответствии с SICP 😃
Пример применения
Давайте перейдем от теории к практике и напишем пример приложения с нуля. Мы создадим валидацию для формы заявки на колонизацию Марса.
Я не буду показывать код разметки, стилей и большую часть работы с DOM, потому что это не так важно для данной темы. Но вы всегда можете посмотреть исходный код на GitHub или найти его в запущенном приложении.
Этот пример намеренно прост. В реальных проектах валидация будет сложнее, а отношения между правилами могут быть более замысловатыми. Но гораздо проще демонстрировать новые концепции на простых примерах. Так что давайте приступим к работе.
Определение правил
Давайте начнем с «ядра» проверки — правил. Предположим, что мы уже выяснили все бизнес-требования и записали их. Допустим, требования таковы:
- телефон и электронная почта должны быть в правильном формате;
- телефон должен начинаться с «+», т.е. быть международным;
- пользователь должен быть не моложе 20 лет и не старше 50;
- пользователь должен выбрать специальность из предложенного списка;
- если специальности нет в списке, пользователь должен указать ее в поле ниже, длина строки в этом случае не должна превышать 50 символов;
- опыт работы должен составлять 3+ года;
- код доступа не должен быть короче 10 символов, иметь хотя бы одну заглавную букву и хотя бы одну цифру.
Все эти правила являются частью домена, потому что для каждого из них есть причина существовать в реальном мире. Телефон и электронная почта необходимы для связи с пользователем. Возраст — от 20 до 50 лет, чтобы колонизаторы могли лучше выжить на борту и на новой планете. Перечисленные специальности имеют более высокий приоритет, потому что колонизаторам больше всего нужны биологи, инженеры, психологи и т.д.
Каждое из этих правил мы уже можем превратить в предикат, но сначала я предлагаю посмотреть на данные, с которыми мы работаем, и смоделировать их.
Моделирование типа формы
В примерах кода я буду использовать TypeScript. Большинство примеров будут практически идентичны коду JavaScript, но если вы все еще не уверены, рекомендую прочитать TypeScript Handbook.
Итак, мы будем представлять данные из формы в виде типа 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-kernvel.d.ts
и добавьте туда эти типы. Кроме них, добавим несколько вспомогательных типов, которые понадобятся нам в будущем:
// shared-kernel.d.ts
// Optional value helpers:
type Nullable<T> = T | null;
type Optional<T> = T | undefined;
// Array wrapper:
type List<T> = T[];
// Since HTML inputs return strings
// we're going to need to have a type
// that reflects our intent to get a number:
type NumberLike = string;
type Comparable = string | number;
// Improving the readability of the code
// and adding details about the domain:
type DateString = string;
type TimeStamp = number;
type NumberYears = number;
type LocalFile = File;
type Image = LocalFile;
Shared kernel — это код и данные, зависимость которых не увеличивает сцепление между модулями. Подробнее об этом я писал в посте о чистой архитектуре на фронтенде. Там же я отсылаю вас к другому посту — «DDD, Hexagonal, Onion, Clean, CQRS, … Как я собрал все это вместе», рекомендую прочитать и его.
Мы описываем форму как ApplicationForm
. Мы будем использовать этот тип в правилах валидации формы в качестве сигнатуры для входных данных.
Реализация критериев валидации
Мы реализуем правила в виде функций-предикатов. Эти функции будут чистыми и будут зависеть только от входных данных. Это означает, что они ничего не будут знать о пользовательском интерфейсе.
Все правила будут принимать на вход объект формы и возвращать булево значение. То есть, они будут предоставлять один и тот же «публичный 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
и используем ее в конкретном случае проверки имени.
Абстракция позволяет нам «убрать ненужные детали». При проверке имени нам неважно, как мы проверяем его существование. Мы «сметаем» детали проверки в отдельную функцию и полагаемся на нее в целом — как на единое действие. Это делает код многоразовым и читабельным.
Перейдем к электронному письму. Предположим, что наше правило проверки электронной почты состоит из двух критериев: «строка должна содержать @» и «строка должна содержать точку». Мы можем объединить такие критерии с помощью 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;
Функция
isNumberLike
достаточно абстрактна, чтобы сделать ее отдельной функцией, принимающей примитив вродеexists
. Но в этот раз мы это пропустим, чтобы избежать лишнего кода.
Наконец, мы проверяем пароль. Он должен быть длиной не менее 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.",
};
Опять же, объектами ошибок и правил могут быть любые объекты, поэтому мы можем описать их как дженерики. Сам валидатор не будет заботиться об этом — его задачей будет прогнать каждое поле через соответствующее правило и записать результат. Поэтому мы создадим не один валидатор, а целую фабрику.
Создание фабрики валидаторов
Фабрика — это объект, который создает другие объекты. В нашем случае это функция, которая будет создавать функции. Мы снова поместим однотипные действия в «суперпрограмму», функцию 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) => {
// Find a validation rule for each field:
const field = key as keyof TData;
const validate = rules[field];
// If no rule skip this field:
if (!validate) return;
// If the value is invalid show an error:
if (!validate(data)) {
result.valid = false;
result.errors[field] = errors[field];
}
});
return result;
};
}
Эта функция принимает правила и ошибки в качестве входных данных и возвращает функцию валидатора. Этот валидатор принимает данные, проверяет каждое поле на соответствие правилу и записывает ошибку, если значение не соответствует.
Функции, которые принимают на вход другие функции или возвращают другие функции, называются функциями высшего порядка. Это один из основных методов управления абстракцией в функциональном программировании.
Мы можем использовать такую фабрику следующим образом:
// validation.ts
export const validateForm = createValidator(rules, errors);
// The validateForm signature will be: ApplicationForm => ValidationResult<ApplicationForm>.
// Thanks to the generics, the function understands which data structure it is going to work with.
Сам валидатор мы можем использовать следующим образом:
// main.ts
// …
const data: ApplicationForm = Object.fromEntries(new FormData(e.target));
const { valid, errors } = validateForm(data);
// If !valid show errors to the user.
// …
Паттерны, сопоставление паттернов и метапрограммирование
Возможно, кто-то посчитает этот подход искаженным шаблоном «Стратегии», а кто-то — «не очень хорошим и не очень хорошо определенным паттерном-совпадением». В целом, вы правы.
Декларативный подход, ориентированный на правила, можно рассматривать как инструмент управления абстракциями. Мы как будто пишем программы, которые могут генерировать большое количество других, более конкретных, но работающих по почти идентичным правилам.
Преимущество в том, что с меньшими усилиями мы можем сгенерировать множество «почти идентичного» кода без дублирования. И раз уж мы заговорили о преимуществах, давайте оценим подход к валидации на основе правил в целом.
Преимущества подхода, основанного на правилах
Я могу насчитать пять из них.
Расширяемость
Добавлять новые правила или изменять существующие становится проще. По крайней мере, всегда понятно, где искать место для обновления. Например, если нам нужно добавить новый критерий для пароля, скажем, чтобы он содержал подстановочный знак, то мы добавляем новую функцию:
const hasSpecialCharacter = ({ password }) =>
contains(password, specialCharactersRegex);
…А затем добавляем его в список правил для проверки пароля:
const passwordRules = [
hasRequiredSize,
hasCapital,
hasDigit,
hasSpecialCharacter,
];
Если нам нужно обновить функцию, например, заменить проверку почты на проверку по регулярному выражению, мы обновим только функцию validateEmail
:
const validateEmail = ({ email }) => emailRegex.test(email);
Если нам нужно добавить новое поле в форму, то мы обновляем список правил и ошибок. Предположим, мы хотим добавить поле с размером одежды, чтобы правильно сшить форму:
const validateSize = ({size}) => // ...The validation rule.
const rules = {
// ...All the other rules.
size: validateSize,
}
const errors = {
// ...All the other error messages.
size: 'Please, use American size chart.'
}
А если вы хотите удалить поле из формы, то нужно просто найти и удалить связанный с ним код.
Читабельность
Декларативный стиль легче читать, чем императивный. При декларативном написании легче выделить различные уровни абстракции и объяснить свое намерение в терминах предметной области. Это позволяет вам не тратить ресурсы на «разбор» ненужных деталей в голове позже, при чтении кода.
Тестируемость
Чистые функции легче тестировать. Вам не нужно «издеваться над сервисами» и «настраивать инфраструктуру» для них, достаточно тест-раннера и тестовых данных. Каждое правило можно тестировать изолированно, а если их много, то можно запускать тесты параллельно.
Сам сервис валидации можно проверить один раз. Если мы убедились, что он правильно составляет функции и прогоняет значение через все правила, нам не нужно будет проверять его каждый раз.
Живая документация
Правила, описанные функциями с понятными именами, можно отдать на проверку «непрограммистам». (Не всегда, конечно, но в идеале — можно.) Такие правила можно сделать частью вездесущего языка из Domain Driven Design.
Скотт Влащин подробно написал о вездесущем языке в книге «Domain Modeling Made Functional». Отличная книга, очень рекомендую.
Отсутствие зависимостей
Вам не нужны никакие сторонние библиотеки для такой валидации. Это может не подойти некоторым людям по разным причинам — для меня это скорее преимущество.
Я вообще стараюсь тщательно выбирать зависимости. Если какую-то функциональность я могу написать сам, и это не будет велосипедом с ошибками и черной дырой с точки зрения ресурсов и времени, то я рассмотрю вариант «написать самому».
Если вам действительно нужна библиотека, то чистые функции не составит труда сопрячь с ней. Особенно если библиотека также поддерживает декларативный подход, как React Hook Form.
В целом, любой подход, основанный на легких абстракциях типа функций, относительно дешево сочетается с библиотеками и сторонними сервисами. Валидация на основе правил — один из таких подходов.
Недостатки
Без недостатков здесь не обойтись. Вот с чем я столкнулся при использовании таких валидаторов.
Необходим контракт на обработку ошибок
Не всегда понятно, что мы должны возвращать в результате валидации. В примере мы возвращаем сообщения об ошибках для каждого правила, но, возможно, нам нужно сообщать об ошибках для каждого критерия или первого символа, в котором произошла ошибка, или что-то еще. Это требование может меняться от проекта к проекту.
Можно вынести определение контракта в отдельную функцию или написать более общий валидатор, но тогда код может стать слишком сложным.
Мне помогает хранить логику инфраструктуры — фабрики, композиторы, бегуны — отдельно от правил. В этом случае заменить или расширить контракт ошибок будет проще.
Производительность и преобразователи
В коде примера есть два правила, которые немного воняют: validateBirthDate
и validateExperience
. Их критериальные функции преобразуют строки в даты и числа и делают это каждый раз, когда их вызывают.
// Date.parse called _twice_ when just _one_ field is checked:
const validDate = ({ birthDate }) => !Number.isNaN(Date.parse(birthDate));
const allowedAge = ({ birthDate }) =>
inRange(yearsOf(Date.parse(birthDate)), MIN_AGE, MAX_AGE);
Сложные структуры могут привести к снижению производительности. В идеале преобразование должно выполняться один раз. (И было бы неплохо покрыть структуру типами до и после преобразования). Мы могли бы использовать функцию type:
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),
};
}
Валидатор как нежелательная зависимость
Если отвлечься, можно случайно перетащить сервис валидации в качестве зависимости во все остальные модули.
Я стараюсь, чтобы логика домена была чистой и не зависела от сторонних сервисов. Обычно это помогает разделить проверку значения и его получение из объекта. В примере с проверкой почты это будет выглядеть следующим образом:
// Domain function, works with a domain primitive:
const isValidEmail = (email: EmailAddress) =>
email.includes("@") && email.includes(".");
// Function in the application layer, works with the whole form object:
const validateEmail = ({ email }: ApplicationForm) => isValidEmail(email);
Тогда бизнес-правила были бы еще чище и независимее, но чаще всего это накладные расходы. Иногда можно пожертвовать «чистотой» ради краткости.
Однако чем чище функции, тем проще проверка DTO при их десериализации.
Я рекомендую прочитать больше о DTO, их сериализации, десериализации и о том, когда следует проверять данные при создании доменных объектов, в книге «Domain Modeling Made Functional» Скотта Влашина.
Вам все еще нужно будет подумать об обработке ошибок. Но об этом мы поговорим в другой раз 😃.
Источники
Как обычно, я собрал огромный список источников и ссылок для текста этого поста. Наслаждайтесь!
Заявка и исходный код
- Форма заявки на участие в программе Mars Colonizer
- Исходники на GitHub
Общие термины компьютерных наук
- Композиция
- Двоичная логика
- Примитивная одержимость
- Сцепление
- Сцепление
- Закон Деметры
Декларативный подход и функциональное программирование
- Чистые функции
- Композиция функций
- Предикатные функции
- Функции высшего порядка.
- Сопоставление с образцом
- Декларативное программирование]
Проектирование и архитектура программного обеспечения
- Доменно-ориентированное проектирование
- Объект передачи данных
- Общее ядро
- Стратегия паттера
- «Доменное моделирование стало функциональным», Скотт Влашин
- Чистая архитектура на фронтенде
- DDD, Hexagonal, Onion, Clean, CQRS, … Как я собрал все это вместе
- Generics в TypeScript
- Справочник по TypeScript
Абстракция и уровни сложности
- Уровень абстракции
- Разделение забот
- Восхождение по бесконечной лестнице абстракции
- Примитивная одержимость
Книги по этой теме
- «Структура и интерпретация компьютерных программ», Г. Абельсон, Г. Дж. Сассман, Дж. Сассман
- «Функциональное моделирование доменов», Скотт Влащин
Другие материалы из моего блога
- Чистая архитектура на фронтенде
- Давайте напишем двоичный сумматор в игре «Жизнь»!
- Генерация изображений деревьев на холсте с помощью L-систем, TypeScript и ООП