Создание пользовательских форматов ответов

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

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

Каждая ошибка валидации в class-validator обычно имеет следующую форму:

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

Главная особенность заключается в том, что children делает структуру рекурсивной, а значит, прямое преобразование в плоский JSON невозможно без обхода дерева.

Причины необходимости кастомного формата ответов

Стандартный формат не подходит для большинства прикладных задач по следующим причинам:

  • избыточная вложенность
  • отсутствие унифицированного формата ошибок
  • сложность обработки на стороне клиента
  • наличие технических деталей (target, children), которые не нужны API-потребителю
  • невозможность стандартизировать ответы разных эндпоинтов

Поэтому возникает необходимость создать собственный формат, который будет:

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

Базовая стратегия преобразования ValidationError

Первый шаг в создании собственного формата — обход дерева ошибок и извлечение значимых данных.

Типовая функция преобразования выглядит как рекурсивный обход:

function flattenErrors(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) {
      result.push(...flattenErrors(error.children));
    }
  }

  return result;
}

В этом подходе теряется структура вложенности, но формируется удобный список ошибок.

Формирование расширенного формата ответа

В реальных API часто требуется не просто список ошибок, а структурированный ответ:

  • код ошибки
  • поле
  • список сообщений
  • уровень вложенности
  • метаинформация

Пример более развитого форматтера:

function formatValidationErrors(errors, parentPath = '') {
  const formatted = [];

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

    if (error.constraints) {
      formatted.push({
        field: path,
        errors: Object.entries(error.constraints).map(
          ([rule, message]) => ({
            rule,
            message,
          })
        ),
      });
    }

    if (error.children && error.children.length) {
      formatted.push(
        ...formatValidationErrors(error.children, path)
      );
    }
  }

  return formatted;
}

Такой формат сохраняет информацию о вложенности через field, не усложняя структуру объекта.

Построение унифицированного API-ответа

Для интеграции в реальный проект обычно создаётся обёртка над результатом:

function createErrorResponse(errors) {
  return {
    success: false,
    code: 'VALIDATION_ERROR',
    timestamp: new Date().toISOString(),
    errors: formatValidationErrors(errors),
  };
}

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

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

В экосистеме NestJS наиболее распространённый способ кастомизации — настройка ValidationPipe через exceptionFactory.

import { BadRequestException } from '@nestjs/common';

const validationPipe = new ValidationPipe({
  exceptionFactory: (errors) => {
    const formatted = formatValidationErrors(errors);

    return new BadRequestException({
      success: false,
      code: 'VALIDATION_ERROR',
      errors: formatted,
    });
  },
});

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

Глубокая работа с вложенными структурами

При работе с вложенными DTO часто возникает необходимость сохранить иерархию. Для этого можно использовать древовидный формат:

function buildTree(errors) {
  return errors.map(error => ({
    field: error.property,
    constraints: error.constraints
      ? Object.values(error.constraints)
      : null,
    children: error.children?.length
      ? buildTree(error.children)
      : [],
  }));
}

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

Обработка массива ошибок и нормализация

class-validator может возвращать несколько ошибок на одно поле, поэтому важно нормализовать данные:

  • объединять повторяющиеся поля
  • агрегировать сообщения
  • устранять дублирование правил

Пример агрегации:

function aggregateErrors(errors) {
  const map = new Map();

  for (const error of errors) {
    const key = error.property;

    if (!map.has(key)) {
      map.set(key, []);
    }

    if (error.constraints) {
      map.get(key).push(
        ...Object.values(error.constraints)
      );
    }
  }

  return Array.from(map.entries()).map(([field, messages]) => ({
    field,
    messages: [...new Set(messages)],
  }));
}

Локализация сообщений в пользовательском формате

При создании собственного формата часто возникает задача локализации. class-validator поддерживает пользовательские сообщения, которые можно переопределять на уровне декораторов.

Однако более гибкий подход заключается в постобработке:

const translations = {
  isNotEmpty: 'Поле не должно быть пустым',
  isEmail: 'Некорректный email',
};

function localizeErrors(errors) {
  return errors.map(error => ({
    field: error.property,
    messages: Object.entries(error.constraints || {}).map(
      ([rule]) => translations[rule] || rule
    ),
  }));
}

Такой слой позволяет отделить бизнес-логику от языковой логики.

Построение масштабируемого форматтера

В крупных приложениях форматирование ошибок выносят в отдельный сервис:

class ValidationFormatter {
  static format(errors) {
    return {
      meta: {
        count: errors.length,
      },
      errors: this.mapErrors(errors),
    };
  }

  static mapErrors(errors, path = '') {
    const result = [];

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

      if (error.constraints) {
        result.push({
          field: currentPath,
          violations: Object.keys(error.constraints).map(
            key => ({
              type: key,
              message: error.constraints[key],
            })
          ),
        });
      }

      if (error.children?.length) {
        result.push(
          ...this.mapErrors(error.children, currentPath)
        );
      }
    }

    return result;
  }
}

Такой класс позволяет централизовать правила преобразования и легко расширять их при росте системы.

Разделение технических и пользовательских данных

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

  • target — объект запроса
  • value — необработанное значение
  • служебные структуры библиотеки

Оставляются только:

  • путь к полю
  • код правила
  • сообщение
  • дополнительные метаданные при необходимости

Это снижает связность API с внутренней реализацией class-validator.

Комбинирование нескольких стратегий форматирования

На практике часто используется гибридный подход:

  • плоский список для простых форм
  • дерево для сложных вложенных DTO
  • агрегированный формат для логирования

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

function format(errors, mode = 'flat') {
  switch (mode) {
    case 'tree':
      return buildTree(errors);
    case 'aggregate':
      return aggregateErrors(errors);
    default:
      return formatValidationErrors(errors);
  }
}

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

Контроль стабильности формата

Создание пользовательского формата требует строгого контроля обратной совместимости. Любое изменение структуры может привести к поломке клиентских приложений. Поэтому формат обычно:

  • фиксируется контрактом API
  • покрывается тестами
  • документируется как часть спецификации ответа