Связка class-validator и React Hook Form позволяет
перенести правила валидации в отдельные классы и использовать
декларативный подход вместо ручной проверки полей. Такой подход особенно
полезен в крупных формах, где логика валидации должна быть
переиспользуемой, расширяемой и централизованной.
Наиболее распространённая схема интеграции выглядит следующим образом:
React Hook Form отвечает за управление состоянием
формы;class-validator выполняет проверку классов;class-transformer преобразует обычные объекты в
экземпляры классов;npm install react-hook-form class-validator class-transformer @hookform/resolvers
Для корректной работы декораторов необходимо включить поддержку
decorators в tsconfig.json.
{
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
Дополнительно часто используется пакет:
npm install reflect-metadata
Импорт обычно добавляется в точке входа приложения:
import 'reflect-metadata';
import {
IsEmail,
IsNotEmpty,
MinLength
} from 'class-validator';
export class LoginDto {
@IsEmail({}, {
message: 'Некорректный email'
})
email: string;
@IsNotEmpty({
message: 'Пароль обязателен'
})
@MinLength(6, {
message: 'Минимум 6 символов'
})
password: string;
}
React Hook Form использует специальные resolver-функции
для интеграции внешних валидаторов.
Для class-validator используется
classValidatorResolver.
import { useForm } from 'react-hook-form';
import { classValidatorResolver } from '@hookform/resolvers/class-validator';
import { LoginDto } from './LoginDto';
const resolver = classValidatorResolver(LoginDto);
import { useForm } from 'react-hook-form';
import { classValidatorResolver } from '@hookform/resolvers/class-validator';
import { LoginDto } from './LoginDto';
const resolver = classValidatorResolver(LoginDto);
export function LoginForm() {
const {
register,
handleSubmit,
formState: { errors }
} = useForm<LoginDto>({
resolver
});
const onSub mit = (data: LoginDto) => {
console.log(data);
};
return (
<form onSub mit={handleSubmit(onSubmit)}>
<div>
<input
type="email"
placeholder="Email"
{...register('email')}
/>
{errors.email && (
<p>{errors.email.message}</p>
)}
</div>
<div>
<input
type="password"
placeholder="Пароль"
{...register('password')}
/>
{errors.password && (
<p>{errors.password.message}</p>
)}
</div>
<button type="submit">
Войти
</button>
</form>
);
}
classValidatorResolverResolver выполняет несколько этапов:
validate;class-validator в формат
React Hook Form.Схема работы:
HTML Form
↓
React Hook Form
↓
Resolver
↓
class-transformer
↓
class-validator
↓
ValidationError[]
↓
Ошибки формы
class-transformerclass-validator корректно работает только с экземплярами
классов. Обычный объект JavaScript не содержит метаданных
декораторов.
Именно поэтому resolver автоматически использует
plainToInstance.
Пример ручного преобразования:
import { plainToInstance } from 'class-transformer';
const dto = plainToInstance(LoginDto, {
email: 'admin@mail.com',
password: '123456'
});
@MinLength(8, {
message: 'Пароль слишком короткий'
})
password: string;
@MinLength(8, {
message: (args) => {
return `Минимум ${args.constraints[0]} символов`;
}
})
password: string;
import {
IsInt,
Min,
Max
} from 'class-validator';
export class ProductDto {
@IsInt()
@Min(1)
@Max(1000)
quantity: number;
}
HTML-формы возвращают строки. Даже
<input type="number"> возвращает текст.
Для автоматической конвертации применяется @Type.
import { Type } from 'class-transformer';
import { IsInt } from 'class-validator';
export class ProductDto {
@Type(() => Number)
@IsInt()
quantity: number;
}
import { IsNotEmpty } from 'class-validator';
export class AddressDto {
@IsNotEmpty()
city: string;
@IsNotEmpty()
street: string;
}
import {
ValidateNested
} from 'class-validator';
import { Type } from 'class-transformer';
export class UserDto {
@ValidateNested()
@Type(() => AddressDto)
address: AddressDto;
}
<input {...register('address.city')} />
<input {...register('address.street')} />
export class SkillDto {
@IsNotEmpty()
name: string;
}
import {
ValidateNested,
ArrayMinSize
} from 'class-validator';
import { Type } from 'class-transformer';
export class UserDto {
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => SkillDto)
skills: SkillDto[];
}
useFieldArrayconst {
control,
register
} = useForm<UserDto>({
resolver
});
const {
fields,
append,
remove
} = useFieldArray({
control,
name: 'skills'
});
{
fields.map((field, index) => (
<div key={field.id}>
<input
{...register(`skills.${index}.name`)}
/>
<button
type="button"
onCl ick={() => remove(index)}
>
Удалить
</button>
</div>
));
}
import {
IsDate
} from 'class-validator';
import {
Type
} from 'class-transformer';
export class EventDto {
@Type(() => Date)
@IsDate()
startDate: Date;
}
Иногда поле требуется только при определённых условиях.
import {
ValidateIf,
IsNotEmpty
} from 'class-validator';
export class PaymentDto {
paymentMethod: string;
@ValidateIf(o => o.paymentMethod === 'card')
@IsNotEmpty()
cardNumber: string;
}
import {
registerDecorator,
ValidationOptions,
ValidationArguments
} from 'class-validator';
export function IsStrongPassword(
validationOptions?: ValidationOptions
) {
return function (
object: Object,
propertyName: string
) {
registerDecorator({
name: 'isStrongPassword',
target: object.constructor,
propertyName,
options: validationOptions,
validator: {
validate(value: any) {
return /[A-Z]/.test(value)
&& /[0-9]/.test(value);
},
defaultMessage(args: ValidationArguments) {
return `${args.property} недостаточно сложный`;
}
}
});
};
}
export class RegisterDto {
@IsStrongPassword()
password: string;
}
class-validator поддерживает async-validator.
import {
ValidatorConstraint,
ValidatorConstraintInterface,
ValidationArguments
} from 'class-validator';
@ValidatorConstraint({ async: true })
export class IsEmailUniqueConstraint
implements ValidatorConstraintInterface {
async validate(email: string) {
const exists = await checkEmail(email);
return !exists;
}
defaultMessage(args: ValidationArguments) {
return 'Email уже используется';
}
}
import {
Validate
} from 'class-validator';
export class RegisterDto {
@Validate(IsEmailUniqueConstraint)
email: string;
}
useForm({
resolver,
mode: 'onSubmit'
});
useForm({
resolver,
mode: 'onChange'
});
useForm({
resolver,
mode: 'onBlur'
});
useForm<LoginDto>({
resolver,
defaultValues: {
email: '',
password: ''
}
});
const {
reset
} = useForm<LoginDto>({
resolver
});
reset();
reset({
email: 'admin@mail.com'
});
Часто сервер возвращает ошибки после отправки формы.
const {
setError
} = useForm<LoginDto>({
resolver
});
setError('email', {
type: 'server',
message: 'Email уже существует'
});
import {
IsEnum
} from 'class-validator';
enum UserRole {
ADMIN = 'admin',
USER = 'user'
}
export class UserDto {
@IsEnum(UserRole)
role: UserRole;
}
import {
IsUrl
} from 'class-validator';
export class ProfileDto {
@IsUrl()
website: string;
}
import {
IsUUID
} from 'class-validator';
export class UserDto {
@IsUUID()
id: string;
}
ControllerНекоторые UI-библиотеки не работают напрямую через
register.
Для таких компонентов используется Controller.
import {
Controller
} from 'react-hook-form';
<Controller
control={control}
name="email"
render={({ field }) => (
<CustomInput
value={field.value}
onCha nge={field.onChange}
/>
)}
/>
<Controller
name="email"
control={control}
render={({ field }) => (
<TextField
{...field}
label="Email"
error={!!errors.email}
helperText={errors.email?.message}
/>
)}
/>
<Controller
name="email"
control={control}
render={({ field }) => (
<Input
{...field}
status={
errors.email ? 'error' : ''
}
/>
)}
/>
React Hook Form минимизирует количество ререндеров,
поскольку использует uncontrolled components.
Использование class-validator практически не влияет на
производительность при небольших формах, однако при работе с крупными
структурами рекомендуется:
mode: 'onSubmit' для тяжёлых форм;watch.Одно из главных преимуществ class-validator —
возможность повторного использования DTO между frontend и backend.
Пример общей структуры:
shared/
├── dto/
│ ├── LoginDto.ts
│ ├── RegisterDto.ts
│ └── UserDto.ts
Такая архитектура:
Причина обычно связана с отсутствием:
"experimentalDecorators": true
или
"emitDecoratorMetadata": true
Частая причина — отсутствие reflect-metadata.
import 'reflect-metadata';
Необходимо использовать:
@Type(() => Number)
Требуются одновременно:
@ValidateNested()
@Type(() => AddressDto)
В крупных React-приложениях DTO обычно разделяются по модулям.
modules/
├── auth/
│ ├── dto/
│ ├── forms/
│ └── validators/
│
├── profile/
│ ├── dto/
│ ├── forms/
│ └── validators/
Дополнительно часто выносятся:
import {
IsEmail,
MinLength,
IsNotEmpty
} from 'class-validator';
export class RegisterDto {
@IsEmail()
email: string;
@MinLength(6)
password: string;
@IsNotEmpty()
username: string;
}
import {
useForm
} from 'react-hook-form';
import {
classValidatorResolver
} from '@hookform/resolvers/class-validator';
import {
RegisterDto
} from './RegisterDto';
const resolver =
classValidatorResolver(RegisterDto);
export function RegisterForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting }
} = useForm<RegisterDto>({
resolver,
mode: 'onBlur'
});
const onSub mit = async (
data: RegisterDto
) => {
console.log(data);
};
return (
<form onSub mit={handleSubmit(onSubmit)}>
<div>
<input
{...register('email')}
placeholder="Email"
/>
<p>
{errors.email?.message}
</p>
</div>
<div>
<input
type="password"
{...register('password')}
placeholder="Пароль"
/>
<p>
{errors.password?.message}
</p>
</div>
<div>
<input
{...register('username')}
placeholder="Имя"
/>
<p>
{errors.username?.message}
</p>
</div>
<button
type="submit"
disabled={isSubmitting}
>
Зарегистрироваться
</button>
</form>
);
}