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

Связка 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

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

Использование в компоненте Formik

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

Преобразование plain object в экземпляр класса

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

Почему необходим @Type

JavaScript не хранит информацию о типах во время выполнения. 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
  • более высокая производительность
  • удобство в простых формах

Создание общего адаптера для Formik

В больших проектах обычно создаётся единая функция:

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

Иногда часть проекта использует Yup, а часть — class-validator.

Formik поддерживает оба подхода одновременно:

<Formik
  validate={createValidator(UserDto)}
  validationSchema={schema}
>

Однако рекомендуется использовать единый стиль валидации во всём проекте.


Типизация Formik и class-validator

export class LoginForm {
  @IsEmail()
  email: string;

  @MinLength(6)
  password: string;
}

Типизация Formik:

<Formik<LoginForm>

Это улучшает:

  • автодополнение
  • проверку типов
  • безопасность рефакторинга

Использование DTO между фронтендом и backend

Одно из главных преимуществ class-validator — переиспользование DTO.

Например:

export class CreateUserDto {
  @IsEmail()
  email: string;

  @MinLength(6)
  password: string;
}

DTO может одновременно использоваться:

  • в NestJS
  • в Formik
  • в API-клиенте
  • в тестах

Это устраняет дублирование правил валидации.


Проблемы производительности

При большом количестве полей возможны частые повторные проверки.

Formik запускает validate:

  • при изменении поля
  • при blur
  • при submit

Оптимизация:

<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

Такой подход обеспечивает:

  • единый источник правил
  • переиспользуемость DTO
  • строгую типизацию
  • удобную масштабируемость
  • сокращение дублирования кода
  • единообразную клиентскую и серверную валидацию