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

Библиотека class-validator возвращает ошибки в виде массива объектов ValidationError, каждый из которых описывает одно нарушенное правило валидации. Эти объекты имеют вложенную структуру и требуют преобразования перед отправкой клиенту.

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

  • property — имя поля, в котором произошла ошибка
  • value — значение, которое не прошло проверку
  • constraints — объект с нарушенными правилами и сообщениями
  • children — вложенные ошибки (для объектов и массивов)
  • target — исходный объект (часто исключается из ответа API)

Пример результата валидации:

[
  {
    property: "email",
    value: "not-an-email",
    constraints: {
      isEmail: "email must be an email"
    }
  }
]

Такой формат неудобен для фронтенда и требует нормализации.


Причины преобразования ошибок в единый формат

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

  • вложенность при проверке объектов и DTO
  • разный формат сообщений у разных валидаторов
  • наличие технических полей (target, value)
  • отсутствие единой структуры ответа API

В результате формируется задача приведения ошибок к стабильному контракту, например:

{
  "status": "error",
  "errors": [
    {
      "field": "email",
      "messages": ["email must be an email"]
    }
  ]
}

Базовое преобразование ValidationError в плоский список

Первый шаг — извлечение сообщений из constraints.

function extractErrors(errors) {
  return errors.map(err => {
    const messages = err.constraints
      ? Object.values(err.constraints)
      : [];

    return {
      field: err.property,
      messages
    };
  });
}

Недостаток подхода — игнорирование вложенных объектов (children).


Обработка вложенных ошибок (рекурсивная нормализация)

При валидации сложных DTO ошибки могут быть вложенными:

class CreateUserDto {
  @ValidateNested()
  profile;
}

Структура ошибки:

{
  property: "profile",
  children: [
    {
      property: "firstName",
      constraints: {
        isNotEmpty: "firstName should not be empty"
      }
    }
  ]
}

Рекурсивная обработка:

function flattenErrors(errors, parentPath = "") {
  let result = [];

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

    if (error.constraints) {
      result.push({
        field: path,
        messages: Object.values(error.constraints)
      });
    }

    if (error.children && error.children.length > 0) {
      result = result.concat(
        flattenErrors(error.children, path)
      );
    }
  }

  return result;
}

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


Унификация формата ответа API

После нормализации ошибок формируется стабильный контракт ответа:

function buildErrorResponse(errors) {
  return {
    status: "error",
    code: "VALIDATION_ERROR",
    errors: flattenErrors(errors)
  };
}

Пример результата:

{
  "status": "error",
  "code": "VALIDATION_ERROR",
  "errors": [
    {
      "field": "email",
      "messages": ["email must be an email"]
    },
    {
      "field": "profile.firstName",
      "messages": ["firstName should not be empty"]
    }
  ]
}

Формирование пользовательских сообщений через ValidationOptions

class-validator позволяет задавать кастомные сообщения:

import { IsEmail } from "class-validator";

class UserDto {
  @IsEmail({}, {
    message: "Некорректный формат email"
  })
  email;
}

При этом в constraints попадёт именно пользовательское сообщение, что упрощает дальнейшее форматирование.


Использование validate и validateOrReject для управления ошибками

Функция validate возвращает массив ошибок:

const errors = await validate(dto);

Функция validateOrReject выбрасывает исключение:

await validateOrReject(dto);

Для API-слоя чаще используется validate, так как позволяет централизованно форматировать ответ.


Централизованное форматирование через слой обработки исключений

Подход с единым обработчиком ошибок:

async function validateDto(dto) {
  const errors = await validate(dto);

  if (errors.length > 0) {
    throw buildErrorResponse(errors);
  }
}

Это позволяет стандартизировать все ответы без дублирования логики в контроллерах.


Форматирование в связке с HTTP-слоем

Типичный вариант ответа HTTP API:

res.status(400).json(
  buildErrorResponse(errors)
);

Важно, чтобы структура ответа оставалась одинаковой во всех эндпоинтах.


Нормализация ключей и адаптация под frontend-контракт

Иногда требуется преобразование field в массив путей:

function toArrayPath(field) {
  return field.split(".");
}

Или более строгий формат:

{
  field: ["profile", "firstName"]
}

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


Фильтрация служебных полей ValidationError

Некоторые поля не должны попадать в ответ:

  • target
  • value
  • children (при плоском формате)

Очистка объекта:

function sanitizeError(error) {
  return {
    property: error.property,
    constraints: error.constraints
      ? Object.values(error.constraints)
      : []
  };
}

Группировка ошибок по полям

Вместо плоского списка возможна агрегация:

function groupErrors(errors) {
  const result = {};

  for (const err of flattenErrors(errors)) {
    if (!result[err.field]) {
      result[err.field] = [];
    }

    result[err.field].push(...err.messages);
  }

  return result;
}

Результат:

{
  "email": ["email must be an email"],
  "profile.firstName": ["should not be empty"]
}

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

При использовании интернационализации вместо строк могут использоваться ключи:

{
  isEmail: "validation.email.invalid"
}

Далее на уровне клиента или сервера происходит маппинг:

function translateErrors(errors, t) {
  return errors.map(err => ({
    field: err.field,
    messages: err.messages.map(t)
  }));
}

Контроль стабильности API-контракта ошибок

Система валидации должна гарантировать:

  • неизменный формат ответа
  • отсутствие технических деталей внутри API
  • предсказуемую структуру errors
  • одинаковую обработку всех DTO

Типовой контракт:

type ErrorResponse = {
  status: "error",
  code: string,
  errors: Array<{
    field: string,
    messages: string[]
  }>
}

Обработка массивов в ValidationError

При работе с массивами ошибок property может содержать индекс:

"items.0.name"

Это требует корректной обработки пути без потери структуры:

function normalizeArrayPaths(field) {
  return field.replace(/\.(\d+)/g, "[$1]");
}

Результат:

items[0].name

Оптимизация глубокой обработки ошибок

При больших DTO рекурсивная обработка может быть затратной, поэтому применяются:

  • ранний возврат при отсутствии children
  • минимизация копирования объектов
  • отказ от лишних преобразований constraints

Оптимизированный шаблон:

function fastFlatten(errors, path = "", acc = []) {
  for (const err of errors) {
    const current = path ? `${path}.${err.property}` : err.property;

    if (err.constraints) {
      acc.push({
        field: current,
        messages: Object.values(err.constraints)
      });
    }

    if (err.children?.length) {
      fastFlatten(err.children, current, acc);
    }
  }

  return acc;
}