Валидация данных форм на клиенте

Клиентская валидация предназначена для проверки данных ещё до отправки формы на сервер. Такой подход позволяет:

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

Библиотека class-validator широко используется в экосистеме TypeScript и JavaScript для декларативной валидации объектов через декораторы. Несмотря на популярность на сервере, библиотека отлично подходит и для проверки клиентских форм.


Установка библиотеки

Для работы библиотеки необходимы:

  • class-validator
  • class-transformer
  • reflect-metadata

Установка через npm:

npm install class-validator class-transformer reflect-metadata

Подключение reflect-metadata:

import 'reflect-metadata';

Для TypeScript необходимо включить декораторы в tsconfig.json:

{
  "experimentalDecorators": true,
  "emitDecoratorMetadata": true
}

Принцип работы Class-validator

Библиотека использует классы как схемы валидации. Каждое свойство описывается набором декораторов.

Пример:

import { IsEmail, Length } from 'class-validator';

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

  @Length(6, 20)
  password: string;
}

Валидация выполняется функцией validate:

import { validate } from 'class-validator';

const form = new LoginForm();

form.email = 'wrong-email';
form.password = '123';

const errors = await validate(form);

console.log(errors);

Структура ValidationError

Результатом проверки является массив объектов ValidationError.

Пример структуры:

[
  {
    property: 'email',
    value: 'wrong-email',
    constraints: {
      isEmail: 'email must be an email'
    }
  }
]

Основные поля:

Поле Описание
property Имя поля
value Полученное значение
constraints Список ошибок
children Вложенные ошибки

Использование в формах

Простая HTML-форма

<form id="register-form">
  <input type="text" id="name">
  <input type="email" id="email">
  <button type="submit">Отправить</button>
</form>

Класс валидации:

import {
  IsEmail,
  Length
} from 'class-validator';

export class RegisterForm {
  @Length(2, 30)
  name: string;

  @IsEmail()
  email: string;
}

Обработка формы:

import { validate } from 'class-validator';

const formElement = document.getElementById('register-form');

formElement.addEventListener('submit', async (event) => {
  event.preventDefault();

  const form = new RegisterForm();

  form.name = document.getElementById('name').value;
  form.email = document.getElementById('email').value;

  const errors = await validate(form);

  if (errors.length > 0) {
    console.log(errors);
    return;
  }

  console.log('Форма корректна');
});

Основные декораторы

Проверка строк

import {
  IsString,
  Length,
  MinLength,
  MaxLength,
  Matches
} from 'class-validator';

Пример:

export class UserForm {
  @IsString()
  @Length(3, 20)
  username: string;

  @Matches(/^[a-zA-Z0-9]+$/)
  login: string;
}

Проверка email

import { IsEmail } from 'class-validator';

export class EmailForm {
  @IsEmail()
  email: string;
}

Проверка чисел

import {
  IsNumber,
  Min,
  Max
} from 'class-validator';

export class ProductForm {
  @IsNumber()
  @Min(1)
  @Max(9999)
  price: number;
}

Проверка булевых значений

import { IsBoolean } from 'class-validator';

export class SettingsForm {
  @IsBoolean()
  darkMode: boolean;
}

Проверка даты

import {
  IsDate,
  MinDate
} from 'class-validator';

export class EventForm {
  @IsDate()
  @MinDate(new Date())
  startDate: Date;
}

Проверка URL

import { IsUrl } from 'class-validator';

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

Пользовательские сообщения ошибок

Каждый декоратор поддерживает параметр message.

import { Length } from 'class-validator';

export class ProfileForm {
  @Length(2, 10, {
    message: 'Имя должно содержать от 2 до 10 символов'
  })
  name: string;
}

Динамические сообщения

Сообщение может формироваться функцией.

import { MinLength } from 'class-validator';

export class PasswordForm {
  @MinLength(8, {
    message: (args) => {
      return `Минимальная длина: ${args.constraints[0]}`;
    }
  })
  password: string;
}

Проверка обязательных полей

import {
  IsNotEmpty,
  IsDefined
} from 'class-validator';

export class ContactForm {
  @IsDefined()
  @IsNotEmpty()
  message: string;
}

Разница:

Декоратор Поведение
IsDefined Проверяет undefined и null
IsNotEmpty Проверяет пустую строку

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

Модель адреса

import { Length } from 'class-validator';

export class Address {
  @Length(2, 50)
  city: string;

  @Length(5, 100)
  street: string;
}

Основная форма

import {
  ValidateNested
} from 'class-validator';

import { Type } from 'class-transformer';

export class UserForm {
  @ValidateNested()
  @Type(() => Address)
  address: Address;
}

Валидация массивов

import {
  IsArray,
  ArrayMinSize,
  IsString
} from 'class-validator';

export class TagsForm {
  @IsArray()
  @ArrayMinSize(1)
  @IsString({ each: true })
  tags: string[];
}

Ключ { each: true } означает применение проверки к каждому элементу массива.


Проверка паролей

import {
  Matches,
  MinLength
} from 'class-validator';

export class PasswordForm {
  @MinLength(8)

  @Matches(/[A-Z]/, {
    message: 'Пароль должен содержать заглавную букву'
  })

  @Matches(/[0-9]/, {
    message: 'Пароль должен содержать цифру'
  })

  password: string;
}

Условная валидация

Декоратор ValidateIf позволяет включать проверку только при выполнении условия.

import {
  ValidateIf,
  IsNotEmpty
} from 'class-validator';

export class PaymentForm {
  paymentType: string;

  @ValidateIf(o => o.paymentType === 'card')
  @IsNotEmpty()
  cardNumber: string;
}

Асинхронная валидация

Class-validator поддерживает асинхронные проверки.

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

import {
  ValidatorConstraint,
  ValidatorConstraintInterface,
  ValidationArguments,
  Validate
} from 'class-validator';

Создание валидатора:

@ValidatorConstraint({ async: true })
export class IsEmailUniqueConstraint
  implements ValidatorConstraintInterface {

  async validate(email: string) {
    const response = await fetch(`/api/check-email/${email}`);
    const result = await response.json();

    return result.isUnique;
  }

  defaultMessage(args: ValidationArguments) {
    return 'Email уже используется';
  }
}

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

export class RegisterForm {
  @Validate(IsEmailUniqueConstraint)
  email: string;
}

Пользовательские валидаторы

Проверка имени пользователя

import {
  registerDecorator,
  ValidationOptions,
  ValidationArguments
} from 'class-validator';

export function IsUsername(
  validationOptions?: ValidationOptions
) {

  return function (
    object: Object,
    propertyName: string
  ) {

    registerDecorator({
      name: 'isUsername',
      target: object.constructor,
      propertyName,
      options: validationOptions,

      validator: {
        validate(value: string) {
          return /^[a-z0-9_]+$/i.test(value);
        },

        defaultMessage(args: ValidationArguments) {
          return 'Недопустимый логин';
        }
      }
    });
  };
}

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

export class UserForm {
  @IsUsername()
  username: string;
}

Работа с class-transformer

Данные формы часто приходят строками. Для преобразования используется библиотека class-transformer.

Преобразование plain object в класс

import { plainToInstance } from 'class-transformer';

const formData = {
  age: '25'
};

const instance = plainToInstance(UserForm, formData);

Автоматическое преобразование типов

import { Type } from 'class-transformer';

export class UserForm {
  @Type(() => Number)
  age: number;
}

validateSync

Синхронная версия валидации:

import { validateSync } from 'class-validator';

const errors = validateSync(form);

Подходит для:

  • локальных проверок;
  • мгновенной валидации;
  • отсутствия асинхронных операций.

Валидация при вводе

Проверка поля в реальном времени

input.addEventListener('input', async () => {
  const form = new UserForm();

  form.username = input.value;

  const errors = await validate(form);

  if (errors.length > 0) {
    showError(errors[0]);
  }
});

Отображение ошибок

Получение текста ошибки

function getErrorMessage(error) {
  return Object.values(error.constraints)[0];
}

Отрисовка ошибок

function renderErrors(errors) {
  errors.forEach(error => {
    const field = document.querySelector(
      `[name="${error.property}"]`
    );

    const message =
      Object.values(error.constraints)[0];

    field.classList.add('invalid');

    const errorBlock =
      document.createElement('div');

    errorBlock.textContent = message;

    field.after(errorBlock);
  });
}

Очистка ошибок

function clearErrors() {
  document
    .querySelectorAll('.error')
    .forEach(el => el.remove());

  document
    .querySelectorAll('.invalid')
    .forEach(el => {
      el.classList.remove('invalid');
    });
}

Валидация нескольких шагов формы

Многошаговая регистрация

export class StepOne {
  @IsEmail()
  email: string;
}

export class StepTwo {
  @MinLength(8)
  password: string;
}

Каждый шаг проверяется отдельно:

const errors = await validate(currentStepData);

Группы валидации

Группы позволяют применять разные правила.

export class UserForm {

  @IsNotEmpty({
    groups: ['create']
  })
  password: string;
}

Проверка:

await validate(form, {
  groups: ['create']
});

Отключение лишних свойств

validate(form, {
  whitelist: true
});

Все свойства без декораторов будут удалены.


Запрет неизвестных полей

validate(form, {
  forbidNonWhitelisted: true
});

Теперь наличие лишних полей вызовет ошибку.


stopAtFirstError

Остановка после первой ошибки:

validate(form, {
  stopAtFirstError: true
});

Полезно для крупных форм.


Типичные проблемы

Декораторы не работают

Причины:

  • не подключён reflect-metadata;
  • отключены декораторы в tsconfig;
  • используется Babel без поддержки metadata.

Числа приходят строками

HTML-формы всегда отправляют строки.

Решение:

@Type(() => Number)

Date не валидируется

IsDate() принимает только объект Date.

Неверно:

date = '2025-01-01'

Верно:

date = new Date('2025-01-01')

Интеграция с React

Пример с useState

const [errors, setErrors] = useState([]);

Валидация:

async function submit() {
  const form = plainToInstance(
    RegisterForm,
    values
  );

  const validationErrors =
    await validate(form);

  setErrors(validationErrors);
}

Интеграция с Vue

const form = reactive(
  new RegisterForm()
);

const errors = ref([]);

Проверка:

errors.value = await validate(form);

Интеграция с Angular

Class-validator особенно популярен в проектах на TypeScript.

Пример:

const form = plainToInstance(
  UserForm,
  this.formGroup.value
);

const errors = await validate(form);

Сравнение с Yup и Zod

Библиотека Особенность
Class-validator Декораторы и классы
Yup Функциональные схемы
Zod TypeScript-first подход
Joi Мощная серверная валидация

Преимущества Class-validator

Декларативность

Правила располагаются прямо возле свойств.

@IsEmail()
email: string;

Повторное использование моделей

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

  • в форме;
  • в API;
  • в серверной валидации;
  • в документации.

Хорошая интеграция с TypeScript

Библиотека активно использует:

  • metadata;
  • типизацию;
  • классы;
  • декораторы.

Ограничения библиотеки

Зависимость от декораторов

В проектах без TypeScript использование может быть неудобным.


Размер бандла

Для небольших приложений библиотека может быть избыточной.


Сложность вложенных схем

При глубокой структуре объектов код становится громоздким.


Практическая архитектура клиентской валидации

Оптимальная структура:

src/
 ├── forms/
 ├── validators/
 ├── models/
 ├── utils/
 └── components/

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

import 'reflect-metadata';

import {
  IsEmail,
  MinLength,
  Matches,
  IsNotEmpty
} from 'class-validator';

export class RegisterForm {

  @IsNotEmpty()
  name: string;

  @IsEmail()
  email: string;

  @MinLength(8)

  @Matches(/[A-Z]/)

  @Matches(/[0-9]/)
  password: string;
}

Валидация:

import {
  plainToInstance
} from 'class-transformer';

import {
  validate
} from 'class-validator';

async function validateForm(data) {

  const form = plainToInstance(
    RegisterForm,
    data
  );

  const errors = await validate(form, {
    whitelist: true,
    stopAtFirstError: true
  });

  return errors;
}

Рекомендации по организации ошибок

Удобный формат:

{
  email: 'Некорректный email',
  password: 'Пароль слишком короткий'
}

Преобразование:

function mapErrors(errors) {
  return errors.reduce((acc, error) => {

    acc[error.property] =
      Object.values(error.constraints)[0];

    return acc;

  }, {});
}

Валидация файлов

import {
  registerDecorator
} from 'class-validator';

export function IsFileSize(maxSize: number) {

  return function (
    object: Object,
    propertyName: string
  ) {

    registerDecorator({
      name: 'isFileSize',

      target: object.constructor,

      propertyName,

      validator: {
        validate(file: File) {
          return file.size <= maxSize;
        }
      }
    });
  };
}

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

export class UploadForm {

  @IsFileSize(1024 * 1024)
  avatar: File;
}

Комбинирование декораторов

export class ProfileForm {

  @IsString()

  @MinLength(2)

  @MaxLength(20)

  @Matches(/^[a-z]+$/i)

  username: string;
}

Декораторы выполняются последовательно.


validateOrReject

Метод выбрасывает исключение при ошибке.

import {
  validateOrReject
} from 'class-validator';

try {

  await validateOrReject(form);

} catch (errors) {

  console.log(errors);

}

Наследование классов

class BaseForm {

  @IsEmail()
  email: string;
}

class RegisterForm extends BaseForm {

  @MinLength(8)
  password: string;
}

Все декораторы наследуются автоматически.


Partial формы

При редактировании объекта часто требуется проверять только часть полей.

class UpdateUserForm {

  @IsOptional()
  @IsEmail()
  email?: string;
}

IsOptional

import {
  IsOptional,
  IsString
} from 'class-validator';

export class UserForm {

  @IsOptional()
  @IsString()
  middleName?: string;
}

Если поле отсутствует — остальные проверки пропускаются.


Композиция валидаторов

function StrongPassword() {
  return applyDecorators(
    MinLength(8),
    Matches(/[A-Z]/),
    Matches(/[0-9]/)
  );
}

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

class RegisterForm {

  @StrongPassword()
  password: string;
}

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

При больших формах полезно:

  • валидировать только изменённые поля;
  • использовать validateSync;
  • включать stopAtFirstError;
  • избегать тяжёлых асинхронных проверок на каждый ввод.

Безопасность клиентской валидации

Клиентская проверка никогда не заменяет серверную.

Причины:

  • JavaScript можно отключить;
  • запрос можно подделать;
  • данные можно отправить напрямую через API.

Клиентская валидация используется исключительно как дополнительный уровень проверки интерфейса.