Связка Formik и class-validator позволяет
использовать декларативную систему валидации на основе классов и
декораторов вместо ручного описания правил через функции или схемы.
Такой подход особенно удобен в крупных приложениях, где модели данных
используются одновременно на клиенте и сервере.
class-validator ориентирован на объектно-ориентированную
архитектуру: поля описываются внутри класса, а ограничения задаются с
помощью декораторов.
Установка зависимостей:
npm install class-validator class-transformer formik
Если используется TypeScript, необходимо включить поддержку декораторов:
{
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
Для корректной работы отражения типов потребуется библиотека:
npm install reflect-metadata
Подключение в точке входа приложения:
import 'reflect-metadata';
Типичная модель формы:
import {
IsEmail,
IsNotEmpty,
Length,
MinLength
} from 'class-validator';
export class RegisterForm {
@IsNotEmpty({
message: 'Имя обязательно'
})
name: string;
@IsEmail({}, {
message: 'Некорректный email'
})
email: string;
@MinLength(6, {
message: 'Минимум 6 символов'
})
password: string;
@Length(10, 100, {
message: 'Описание должно содержать от 10 до 100 символов'
})
description: string;
}
Каждый декоратор добавляет правило проверки для конкретного свойства.
Formik ожидает функцию validate, возвращающую объект
ошибок. class-validator возвращает массив объектов
ValidationError, поэтому требуется преобразование
форматов.
Пример универсальной функции:
import { validate } from 'class-validator';
import { plainToInstance } from 'class-transformer';
export async function validateFormik<T>(
cls: new () => T,
values: object
) {
const instance = plainToInstance(cls, values);
const errors = await validate(instance);
const formattedErrors = {};
errors.forEach(error => {
if (error.constraints) {
formattedErrors[error.property] =
Object.values(error.constraints)[0];
}
});
return formattedErrors;
}
import { Formik, Form, Field, ErrorMessage } from 'formik';
import { RegisterForm } from './RegisterForm';
import { validateFormik } from './validateFormik';
export default function RegisterPage() {
return (
<Formik
initialValues={{
name: '',
email: '',
password: '',
description: ''
}}
validate={(values) =>
validateFormik(RegisterForm, values)
}
onSub mit={(values) => {
console.log(values);
}}
>
<Form>
<div>
<Field name="name" />
<ErrorMessage name="name" />
</div>
<div>
<Field name="email" />
<ErrorMessage name="email" />
</div>
<div>
<Field
name="password"
type="password"
/>
<ErrorMessage name="password" />
</div>
<div>
<Field
as="textarea"
name="description"
/>
<ErrorMessage name="description" />
</div>
<button type="submit">
Отправить
</button>
</Form>
</Formik>
);
}
Formik хранит данные как обычный объект:
{
email: 'admin@test.com'
}
Однако class-validator работает с экземплярами
классов.
Именно поэтому используется:
plainToInstance()
Пример:
const user = plainToInstance(UserDto, values);
Без этого декораторы не будут обработаны корректно.
class-validator поддерживает асинхронные валидаторы. Это
особенно полезно при проверке уникальности email, логина или номера
телефона.
import {
ValidatorConstraint,
ValidatorConstraintInterface,
ValidationArguments
} from 'class-validator';
@ValidatorConstraint({ async: true })
export class IsEmailUnique
implements ValidatorConstraintInterface {
async validate(email: string) {
const response = await fetch(
`/api/check-email?email=${email}`
);
const data = await response.json();
return data.isUnique;
}
defaultMessage(args: ValidationArguments) {
return 'Email уже используется';
}
}
import {
Validate
} from 'class-validator';
export class RegisterForm {
@Validate(IsEmailUnique)
email: string;
}
Formik автоматически дождётся завершения промиса внутри
validate.
По умолчанию пример выше возвращает только первую ошибку:
formattedErrors[error.property] =
Object.values(error.constraints)[0];
Для вывода всех сообщений:
formattedErrors[error.property] =
Object.values(error.constraints);
Тогда ошибка будет массивом:
{
password: [
'Минимум 6 символов',
'Пароль слишком простой'
]
}
Вывод:
{
Array.isArray(errors.password) &&
errors.password.map(error => (
<div key={error}>{error}</div>
))
}
Форма может содержать сложные структуры:
{
profile: {
firstName: '',
lastName: ''
}
}
import {
ValidateNested,
IsNotEmpty
} from 'class-validator';
import { Type } from 'class-transformer';
class Profile {
@IsNotEmpty()
firstName: string;
@IsNotEmpty()
lastName: string;
}
export class UserForm {
@ValidateNested()
@Type(() => Profile)
profile: Profile;
}
@TypeJavaScript не хранит информацию о типах во время выполнения.
class-transformer не способен автоматически определить тип
вложенного объекта.
Декоратор:
@Type(() => Profile)
сообщает системе, какой класс необходимо создать.
Вложенные ошибки содержатся внутри children.
Пример рекурсивного преобразователя:
function formatErrors(errors) {
const result = {};
errors.forEach(error => {
if (error.constraints) {
result[error.property] =
Object.values(error.constraints)[0];
}
if (error.children?.length) {
result[error.property] =
formatErrors(error.children);
}
});
return result;
}
import {
ArrayMinSize,
ArrayMaxSize
} from 'class-validator';
export class PostForm {
@ArrayMinSize(1, {
message: 'Добавьте минимум один тег'
})
@ArrayMaxSize(5, {
message: 'Максимум 5 тегов'
})
tags: string[];
}
import {
IsString
} from 'class-validator';
export class PostForm {
@IsString({
each: true
})
tags: string[];
}
each: true указывает, что правило применяется к каждому
элементу массива.
Иногда поле должно проверяться только при определённых условиях.
Пример:
import {
ValidateIf,
IsNotEmpty
} from 'class-validator';
export class PaymentForm {
paymentType: string;
@ValidateIf(
object => object.paymentType === 'card'
)
@IsNotEmpty({
message: 'Введите номер карты'
})
cardNumber: string;
}
Если выбран другой тип оплаты, проверка не выполняется.
Группы позволяют переиспользовать один класс для разных сценариев.
import {
IsNotEmpty
} from 'class-validator';
export class UserForm {
@IsNotEmpty({
groups: ['create']
})
password: string;
}
Проверка:
validate(instance, {
groups: ['create']
});
Для обновления пользователя пароль можно не валидировать.
class-validator содержит множество опций:
validate(instance, {
skipMissingProperties: true,
whitelist: true,
forbidNonWhitelisted: true
});
skipMissingPropertiesИгнорирует отсутствующие поля.
whitelistУдаляет свойства, не описанные в классе.
forbidNonWhitelistedВызывает ошибку при наличии лишних полей.
После валидации объект может быть автоматически очищен:
const user = plainToInstance(UserDto, values);
await validate(user, {
whitelist: true
});
console.log(user);
Лишние поля будут удалены.
Сообщения можно задавать вручную:
@IsEmail({}, {
message: 'Некорректный формат email'
})
Либо через функцию:
@MinLength(8, {
message: args => {
return `Минимальная длина: ${args.constraints[0]}`;
}
})
Пример интеграции с i18next:
@IsNotEmpty({
message: () => i18n.t('errors.required')
})
name: string;
Сообщение будет определяться текущим языком приложения.
import {
IsDate,
MinDate
} from 'class-validator';
export class EventForm {
@IsDate()
@MinDate(new Date())
eventDate: Date;
}
Преобразование строки в Date:
import { Type } from 'class-transformer';
@Type(() => Date)
eventDate: Date;
import {
IsInt,
Min,
Max
} from 'class-validator';
export class ProductForm {
@IsInt()
@Min(1)
@Max(1000)
quantity: number;
}
Преобразование строки:
@Type(() => Number)
quantity: number;
validateSyncЕсли асинхронная проверка не требуется:
import { validateSync } from 'class-validator';
const errors = validateSync(instance);
Преимущества:
PromiseВ больших проектах обычно создаётся единая функция:
import { validate } from 'class-validator';
import { plainToInstance } from 'class-transformer';
export function createValidator(ClassType) {
return async function(values) {
const instance =
plainToInstance(ClassType, values);
const errors = await validate(instance);
return errors.reduce((acc, error) => {
if (error.constraints) {
acc[error.property] =
Object.values(error.constraints)[0];
}
return acc;
}, {});
};
}
Использование:
<Formik
validate={createValidator(RegisterForm)}
>
Иногда часть проекта использует Yup, а часть —
class-validator.
Formik поддерживает оба подхода одновременно:
<Formik
validate={createValidator(UserDto)}
validationSchema={schema}
>
Однако рекомендуется использовать единый стиль валидации во всём проекте.
class-validatorexport class LoginForm {
@IsEmail()
email: string;
@MinLength(6)
password: string;
}
Типизация Formik:
<Formik<LoginForm>
Это улучшает:
Одно из главных преимуществ class-validator —
переиспользование DTO.
Например:
export class CreateUserDto {
@IsEmail()
email: string;
@MinLength(6)
password: string;
}
DTO может одновременно использоваться:
Это устраняет дублирование правил валидации.
При большом количестве полей возможны частые повторные проверки.
Formik запускает validate:
Оптимизация:
<Formik
validateOnChange={false}
validateOnBlur={true}
>
Либо использовать debounce.
Полная проверка формы может быть дорогой.
Возможен частичный подход:
validate(instance, {
groups: ['step1']
});
Или ручная проверка отдельного свойства:
validate(instance, {
skipMissingProperties: true
});
Ошибки backend можно объединять с ошибками
class-validator.
Пример:
setErrors({
email: 'Пользователь уже существует'
});
Formik автоматически отобразит сообщение рядом с полем.
setFieldErrorДля точечной установки ошибки:
setFieldError(
'email',
'Некорректный email'
);
Полезно при асинхронных API-проверках.
Типичная архитектура:
Formik
↓
validate()
↓
plainToInstance()
↓
class-validator
↓
ValidationError[]
↓
Преобразование ошибок
↓
Formik errors
Такой подход обеспечивает: