Отображение ошибок в пользовательском интерфейсе

Валидация данных бесполезна без корректного представления ошибок пользователю. Библиотека class-validator возвращает структурированную информацию о нарушениях правил, которую можно преобразовать в удобный интерфейс: сообщения под полями формы, подсветку ошибок, всплывающие уведомления, списки проблем или API-ответы.

Результат работы validate() представляет собой массив объектов ValidationError:

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

class UserDto {
  @IsEmail({}, { message: 'Некорректный email' })
  email: string

  @Length(5, 20, {
    message: 'Пароль должен содержать от 5 до 20 символов',
  })
  password: string
}

const dto = new UserDto()

dto.email = 'wrong'
dto.password = '123'

const errors = await validate(dto)

console.log(errors)

Пример структуры ошибки:

[
  {
    property: 'email',
    value: 'wrong',
    constraints: {
      isEmail: 'Некорректный email'
    }
  },
  {
    property: 'password',
    value: '123',
    constraints: {
      isLength: 'Пароль должен содержать от 5 до 20 символов'
    }
  }
]

Основные поля объекта ValidationError:

Поле Описание
property Имя поля
value Переданное значение
constraints Объект с сообщениями ошибок
children Ошибки вложенных объектов

Преобразование ошибок в простой формат

Интерфейс обычно не работает напрямую со структурой ValidationError. Чаще всего ошибки преобразуются в объект вида:

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

Пример преобразования:

import { ValidationError } from 'class-validator'

function formatErrors(errors: ValidationError[]) {
  const result = {}

  for (const error of errors) {
    result[error.property] = Object.values(error.constraints || {})
  }

  return result
}

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

const validationErrors = await validate(dto)

const formatted = formatErrors(validationErrors)

console.log(formatted)

Результат:

{
  email: ['Некорректный email'],
  password: ['Пароль должен содержать от 5 до 20 символов']
}

Такой формат удобно использовать в:

  • React
  • Vue
  • Angular
  • NestJS
  • REST API
  • GraphQL

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

Типичная схема отображения ошибок в React:

const [errors, setErrors] = useState({})

Проверка формы:

async function handleSubmit() {
  const dto = new UserDto()

  dto.email = email
  dto.password = password

  const validationErrors = await validate(dto)

  if (validationErrors.length > 0) {
    setErrors(formatErrors(validationErrors))
    return
  }

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

Вывод ошибок:

<div>
  <input
    value={email}
    onCha nge={(e) => setEmail(e.target.value)}
  />

  {errors.email && (
    <p>{errors.email[0]}</p>
  )}
</div>

Подсветка невалидных полей

Ошибки часто сопровождаются изменением стиля элемента:

<input
  className={errors.email ? 'input error' : 'input'}
/>

CSS:

.input {
  border: 1px solid #ccc;
}

.input.error {
  border-color: red;
}

Возможные варианты визуализации:

  • красная рамка;
  • изменение фона;
  • иконка ошибки;
  • анимация;
  • текст под полем;
  • tooltip;
  • всплывающее уведомление.

Отображение нескольких ошибок для одного поля

Одно поле может нарушать несколько ограничений одновременно.

Пример:

class PasswordDto {
  @Length(8, 30, {
    message: 'Минимум 8 символов',
  })
  @Matches(/[A-Z]/, {
    message: 'Требуется заглавная буква',
  })
  @Matches(/[0-9]/, {
    message: 'Требуется цифра',
  })
  password: string
}

Результат:

{
  password: [
    'Минимум 8 символов',
    'Требуется заглавная буква',
    'Требуется цифра'
  ]
}

Отображение:

<ul>
  {errors.password?.map((error) => (
    <li key={error}>{error}</li>
  ))}
</ul>

Кастомизация текстов ошибок

Текст ошибки задаётся через параметр message.

@IsEmail({}, {
  message: 'Введите корректный email'
})
email: string

Поддерживается функция:

@Length(5, 10, {
  message: (args) => {
    return `Длина поля ${args.property} должна быть от 5 до 10`
  }
})
username: string

Аргумент содержит:

Поле Описание
value Значение
property Имя поля
targetName Имя класса
constraints Параметры валидатора

Локализация сообщений

Ошибки интерфейса часто переводятся через i18n-системы.

Пример:

@IsEmail({}, {
  message: 'validation.email.invalid'
})
email: string

На клиенте:

t(errors.email[0])

Преимущества:

  • единая система переводов;
  • поддержка нескольких языков;
  • централизованное хранение текстов;
  • независимость DTO от UI.

Глобальный формат ошибок API

В REST API ошибки обычно возвращаются в едином формате.

Пример ответа:

{
  "errors": {
    "email": [
      "Некорректный email"
    ],
    "password": [
      "Минимум 8 символов"
    ]
  }
}

Формирование ответа:

function buildErrorResponse(errors: ValidationError[]) {
  const result = {}

  for (const error of errors) {
    result[error.property] = Object.values(
      error.constraints || {}
    )
  }

  return {
    errors: result,
  }
}

Обработка вложенных объектов

При использовании вложенных DTO ошибки находятся в children.

Пример DTO:

class AddressDto {
  @Length(2, 50)
  city: string
}

class UserDto {
  @ValidateNested()
  @Type(() => AddressDto)
  address: AddressDto
}

Ошибка:

[
  {
    property: 'address',
    children: [
      {
        property: 'city',
        constraints: {
          isLength: 'city must be longer...'
        }
      }
    ]
  }
]

Рекурсивное преобразование вложенных ошибок

Для сложных форм требуется рекурсивная обработка.

Пример:

function parseErrors(
  errors: ValidationError[],
  parent = ''
) {
  const result = {}

  for (const error of errors) {
    const path = parent
      ? `${parent}.${error.property}`
      : error.property

    if (error.constraints) {
      result[path] = Object.values(error.constraints)
    }

    if (error.children?.length) {
      Object.assign(
        result,
        parseErrors(error.children, path)
      )
    }
  }

  return result
}

Результат:

{
  'address.city': [
    'Название города слишком короткое'
  ]
}

Работа с динамическими формами

В динамических формах поля могут генерироваться автоматически.

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

{
  fields: [
    {
      name: 'email',
      value: '',
      errors: []
    }
  ]
}

Ошибки связываются по имени поля:

field.errors = formattedErrors[field.name] || []

Это особенно полезно при:

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

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

Пример DTO:

class TagDto {
  @Length(2, 20)
  name: string
}

class PostDto {
  @ValidateNested({ each: true })
  @Type(() => TagDto)
  tags: TagDto[]
}

Ошибки:

{
  'tags.0.name': [
    'Минимум 2 символа'
  ]
}

Интерфейс может отображать ошибку рядом с конкретным элементом массива.


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

Популярная схема интеграции:

npm install @hookform/resolvers

Пример:

import { classValidatorResolver } from '@hookform/resolvers/class-validator'
import { useForm } from 'react-hook-form'

const resolver = classValidatorResolver(UserDto)

const {
  register,
  handleSubmit,
  formState: { errors },
} = useForm({
  resolver,
})

Вывод:

<input {...register('email')} />

<p>{errors.email?.message}</p>

Преимущества:

  • автоматическое связывание ошибок;
  • минимальный объём кода;
  • интеграция с DTO;
  • единый источник правил.

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

В NestJS ошибки валидации обычно обрабатываются через ValidationPipe.

Настройка:

app.useGlobalPipes(
  new ValidationPipe({
    whitelist: true,
    transform: true,
  }),
)

Ответ по умолчанию:

{
  "statusCode": 400,
  "message": [
    "email must be an email"
  ],
  "error": "Bad Request"
}

Кастомный exceptionFactory

Формат ошибок можно полностью изменить.

new ValidationPipe({
  exceptionFactory: (errors) => {
    return new BadRequestException({
      errors: formatErrors(errors),
    })
  },
})

Результат:

{
  "errors": {
    "email": [
      "Некорректный email"
    ]
  }
}

Скрытие служебной информации

По умолчанию ValidationError может содержать:

  • исходный объект;
  • значение поля;
  • метаданные класса.

Для API это иногда нежелательно.

Настройка:

validate(dto, {
  validationError: {
    target: false,
    value: false,
  },
})

Результат становится безопаснее:

[
  {
    "property": "email",
    "constraints": {
      "isEmail": "Некорректный email"
    }
  }
]

Отображение ошибок в реальном времени

Проверка может запускаться:

  • при отправке формы;
  • при потере фокуса;
  • при вводе;
  • после изменения поля.

Пример проверки при вводе:

async function validateEmail(value: string) {
  const dto = new UserDto()

  dto.email = value

  const errors = await validate(dto)

  return formatErrors(errors)
}

Debounce при онлайн-валидации

Постоянная проверка при каждом символе может создавать нагрузку.

Пример debounce:

const debouncedValidate = debounce(async (value) => {
  const errors = await validateEmail(value)

  setErrors(errors)
}, 300)

Это уменьшает:

  • количество ререндеров;
  • нагрузку на CPU;
  • количество API-запросов.

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

Ошибка обычно показывается только после взаимодействия с полем.

Пример:

{
  touched.email && errors.email && (
    <p>{errors.email[0]}</p>
  )
}

Такой подход улучшает UX:

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

Группировка ошибок

Иногда требуется отображать ошибки списком.

Пример:

<div>
  {Object.entries(errors).map(([field, messages]) => (
    <div key={field}>
      <strong>{field}</strong>

      <ul>
        {messages.map((msg) => (
          <li key={msg}>{msg}</li>
        ))}
      </ul>
    </div>
  ))}
</div>

Приоритет ошибок

Не всегда требуется показывать все ошибки одновременно.

Пример показа только первой ошибки:

const firstError =
  Object.values(error.constraints || {})[0]

Интерфейс становится менее перегруженным.


Преобразование технических названий полей

Названия DTO-полей часто непригодны для интерфейса.

Плохой вариант:

userName

Лучший вариант:

Имя пользователя

Таблица отображения:

const labels = {
  userName: 'Имя пользователя',
  email: 'Email',
}

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

const fieldLabel = labels[field]

Автоматическое связывание ошибок с полями

Распространённая архитектура:

{
  values: {},
  errors: {},
  touched: {},
}

Пример:

errors.email
errors.password
errors.profile.city

Такое представление совместимо практически со всеми UI-библиотеками.


Асинхронные ошибки

Некоторые валидаторы работают асинхронно.

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

@ValidatorConstraint({ async: true })
class IsEmailUniqueConstraint {
  async validate(email: string) {
    return !(await userExists(email))
  }
}

Интерфейс должен учитывать состояния:

  • загрузка;
  • успешная проверка;
  • ошибка;
  • сетевой сбой.

Отображение серверных ошибок

Некоторые ошибки невозможно проверить на клиенте.

Например:

  • email уже существует;
  • токен устарел;
  • данные изменились;
  • объект удалён.

Сервер может вернуть:

{
  "errors": {
    "email": [
      "Пользователь уже существует"
    ]
  }
}

Интерфейс обрабатывает ответ так же, как локальную валидацию.


Унификация клиентских и серверных ошибок

Хорошая практика — одинаковая структура ошибок на клиенте и сервере.

Пример:

type ValidationErrors = {
  [key: string]: string[]
}

Преимущества:

  • единый UI-код;
  • переиспользование компонентов;
  • упрощение поддержки;
  • независимость от источника ошибок.

Компонент универсального отображения ошибок

Пример переиспользуемого компонента:

type Props = {
  errors?: string[]
}

export function FieldErrors({
  errors,
}: Props) {
  if (!errors?.length) {
    return null
  }

  return (
    <ul>
      {errors.map((error) => (
        <li key={error}>{error}</li>
      ))}
    </ul>
  )
}

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

<FieldErrors errors={errors.email} />

Хранение ошибок в state-менеджерах

Ошибки могут храниться в:

  • Redux;
  • Zustand;
  • MobX;
  • Vuex;
  • Pinia.

Пример:

{
  form: {
    values: {},
    errors: {},
  }
}

Это позволяет:

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

Типизация ошибок в TypeScript

Типизация уменьшает количество ошибок интерфейса.

Пример:

type FormErrors<T> = {
  [K in keyof T]?: string[]
}

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

type UserErrors = FormErrors<UserDto>

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

Ошибки обычно очищаются:

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

Пример:

setErrors({})

Либо для конкретного поля:

setErrors((prev) => ({
  ...prev,
  email: undefined,
}))

Визуальное поведение качественного UI ошибок

Хорошая система отображения ошибок обладает следующими свойствами:

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