Форматирование ошибок для API

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

Базовая ошибка в class-validator представлена объектом ValidationError, который содержит несколько важных полей:

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

Типичная структура выглядит так:

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

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

Проблема прямой передачи ошибок в API

Возврат ValidationError[] напрямую приводит к ряду проблем:

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

API должен возвращать плоскую и стабильную структуру, независимую от реализации валидатора.

Базовый формат API-ошибок

На практике чаще всего используется единый формат:

{
  "statusCode": 400,
  "message": "Validation failed",
  "errors": [
    {
      "field": "email",
      "messages": [
        "email must be an email",
        "email should not be empty"
      ]
    }
  ]
}

Такой формат обеспечивает:

  • простую обработку на клиенте
  • отсутствие зависимости от class-validator
  • возможность расширения (коды ошибок, i18n, метаданные)

Преобразование ValidationError в плоскую структуру

Основная задача — рекурсивно пройти по дереву ValidationError и собрать ошибки.

Базовая функция форматирования

function formatErrors(errors) {
  const result = [];

  for (const error of errors) {
    if (error.constraints) {
      result.push({
        field: error.property,
        messages: Object.values(error.constraints)
      });
    }

    if (error.children && error.children.length > 0) {
      const childrenErrors = formatErrors(error.children).map(child => ({
        field: `${error.property}.${child.field}`,
        messages: child.messages
      }));

      result.push(...childrenErrors);
    }
  }

  return result;
}

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

При работе с DTO вида:

class Address {
  @IsNotEmpty()
  city: string;
}

class User {
  @ValidateNested()
  address: Address;
}

ошибки будут вложенными:

address: {
  children: [
    {
      property: "city",
      constraints: {
        isNotEmpty: "city should not be empty"
      }
    }
  ]
}

После форматирования:

{
  "field": "address.city",
  "messages": ["city should not be empty"]
}

Нормализация путей полей

Для сложных структур важно корректно формировать путь поля. Используются два подхода:

Точечная нотация

user.address.city

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

  • совместимость с JSONPath-подобными подходами
  • удобство логирования

Массивная нотация

user.addresses[0].city

Используется при валидации массивов:

if (Array.isArray(error.children)) {
  // индекс может быть частью property или metadata
}

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

class-validator не всегда явно передаёт индекс элемента массива, поэтому часто требуется дополнительная логика.

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

users: [
  {
    email: {
      constraints: {
        isEmail: "invalid email"
      }
    }
  }
]

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

{
  "field": "users[0].email",
  "messages": ["invalid email"]
}

Для этого обычно требуется обогащение DTO контекстом индекса на уровне бизнес-логики или кастомных валидаторов.

Удаление служебных данных

В API-формате обычно исключаются поля:

  • target
  • value
  • children (после обработки)
  • constraints (после преобразования)

Пример очистки:

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

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

Альтернативный формат API — группировка сообщений:

{
  "email": [
    "must be an email",
    "should not be empty"
  ],
  "password": [
    "too short"
  ]
}

Реализация:

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

  for (const error of errors) {
    const messages = error.constraints
      ? Object.values(error.constraints)
      : [];

    if (!result[error.property]) {
      result[error.property] = [];
    }

    result[error.property].push(...messages);

    if (error.children?.length) {
      const childErrors = groupErrors(error.children);

      for (const [key, value] of Object.entries(childErrors)) {
        const fullKey = `${error.property}.${key}`;

        if (!result[fullKey]) {
          result[fullKey] = [];
        }

        result[fullKey].push(...value);
      }
    }
  }

  return result;
}

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

class-validator поддерживает кастомные сообщения, что позволяет внедрять i18n-слой.

Пример:

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

Далее на уровне API можно производить трансляцию:

function localizeMessages(messages, t) {
  return messages.map(msg => t(msg));
}

где t — функция перевода.

Кастомные форматы ошибок для разных окружений

Development

{
  "field": "email",
  "messages": ["email must be an email"],
  "value": "test",
  "constraints": {
    "isEmail": "email must be an email"
  }
}

Production

{
  "field": "email",
  "messages": ["Invalid email"]
}

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

Интеграция с pipeline валидации

В типичном middleware или pipe:

import { validate } from "class-validator";

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

  if (errors.length > 0) {
    throw {
      statusCode: 400,
      errors: formatErrors(errors)
    };
  }
}

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

Унификация формата ошибок

Для масштабируемых API вводится единый контракт:

  • statusCode
  • errorCode
  • message
  • errors

Пример:

{
  "statusCode": 400,
  "errorCode": "VALIDATION_ERROR",
  "message": "Request validation failed",
  "errors": [
    {
      "field": "email",
      "messages": ["Invalid email"]
    }
  ]
}

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

Обработка частичных ошибок и ранняя остановка

Некоторые системы используют режим stopAtFirstError, однако class-validator по умолчанию собирает все ошибки. Это влияет на форматирование:

  • при полном сборе требуется агрегация
  • при частичном — структура становится плоской автоматически

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