Использование с React Hook Form

Связка class-validator и React Hook Form позволяет перенести правила валидации в отдельные классы и использовать декларативный подход вместо ручной проверки полей. Такой подход особенно полезен в крупных формах, где логика валидации должна быть переиспользуемой, расширяемой и централизованной.

Наиболее распространённая схема интеграции выглядит следующим образом:

  • React Hook Form отвечает за управление состоянием формы;
  • class-validator выполняет проверку классов;
  • class-transformer преобразует обычные объекты в экземпляры классов;
  • resolver связывает обе библиотеки между собой.

Установка зависимостей

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;
}

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

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>
  );
}

Как работает classValidatorResolver

Resolver выполняет несколько этапов:

  1. Получает данные формы;
  2. Преобразует объект в экземпляр класса;
  3. Запускает validate;
  4. Конвертирует ошибки class-validator в формат React Hook Form.

Схема работы:

HTML Form
    ↓
React Hook Form
    ↓
Resolver
    ↓
class-transformer
    ↓
class-validator
    ↓
ValidationError[]
    ↓
Ошибки формы

Использование class-transformer

class-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;
}

Валидация вложенных объектов

DTO адреса

import { IsNotEmpty } from 'class-validator';

export class AddressDto {
  @IsNotEmpty()
  city: string;

  @IsNotEmpty()
  street: string;
}

Основной DTO

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')} />

Массивы объектов

DTO элемента массива

export class SkillDto {
  @IsNotEmpty()
  name: string;
}

DTO пользователя

import {
  ValidateNested,
  ArrayMinSize
} from 'class-validator';

import { Type } from 'class-transformer';

export class UserDto {
  @ArrayMinSize(1)
  @ValidateNested({ each: true })
  @Type(() => SkillDto)
  skills: SkillDto[];
}

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

const {
  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.

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

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;
}

Режимы валидации React Hook Form

Проверка при submit

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 уже существует'
});

Валидация enum

import {
  IsEnum
} from 'class-validator';

enum UserRole {
  ADMIN = 'admin',
  USER = 'user'
}

export class UserDto {
  @IsEnum(UserRole)
  role: UserRole;
}

Валидация URL

import {
  IsUrl
} from 'class-validator';

export class ProfileDto {
  @IsUrl()
  website: string;
}

Валидация UUID

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}
    />
  )}
/>

Интеграция с Material UI

<Controller
  name="email"
  control={control}

  render={({ field }) => (
    <TextField
      {...field}
      label="Email"
      error={!!errors.email}
      helperText={errors.email?.message}
    />
  )}
/>

Интеграция с Ant Design

<Controller
  name="email"
  control={control}

  render={({ field }) => (
    <Input
      {...field}
      status={
        errors.email ? 'error' : ''
      }
    />
  )}
/>

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

React Hook Form минимизирует количество ререндеров, поскольку использует uncontrolled components.

Использование class-validator практически не влияет на производительность при небольших формах, однако при работе с крупными структурами рекомендуется:

  • избегать чрезмерной вложенности;
  • минимизировать async-validator;
  • использовать mode: 'onSubmit' для тяжёлых форм;
  • разделять большие формы на секции;
  • избегать частых watch.

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

Одно из главных преимуществ 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/

Дополнительно часто выносятся:

  • кастомные декораторы;
  • общие валидаторы;
  • схемы трансформации;
  • типы ошибок;
  • фабрики resolver-функций.

Пример полноценной формы регистрации

DTO

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>
  );
}