← назад к разделу

Когда клиент получает ответ от API, он видит JSON. Как выглядят поля, как записаны даты, что происходит с пустыми значениями — всё это важно для совместимости. В NestJS большинство правил выполняется само: TypeScript уже пишет поля в camelCase, а Date превращается в ISO 8601 при любом JSON.stringify. Нужно лишь разобраться с несколькими нюансами, которые требуют явного решения.

Имена полей

В Java и Python принято называть поля с подчёркиванием: customer_id, created_at. Для REST API из Java-сервиса часто нужно настраивать маппер, чтобы поля в JSON были в camelCase.

В TypeScript и NestJS этой проблемы нет: поля класса уже пишутся в camelCase, и в JSON они попадают без какой-либо дополнительной настройки.

export class OrderResponse {
  orderId: string;
  customerId: string;
  createdAt: Date;        // → "2026-05-26T10:30:00Z" в JSON
  totalAmount: number;
  status: OrderStatus;
  items: OrderItemResponse[];
}

Несколько соглашений по именованию:

  • Идентификаторы именуют с суффиксом Id: orderId, customerId, а не просто id.
  • Поля с датой именуют с суффиксом At: createdAt, updatedAt.
  • Поля-коллекции — во множественном числе: items, tags.

Частая ошибка — написать customer_id или created_at в snake_case. Такое поле попадёт в JSON как есть, и клиент получит неожиданное имя.

Даты и время

Объект Date в TypeScript при сериализации в JSON превращается в строку формата ISO 8601 автоматически:

const date = new Date('2026-05-26T10:30:00Z');
JSON.stringify({ createdAt: date });
// → {"createdAt":"2026-05-26T10:30:00.000Z"}

Клиент всегда получает стандартную строку — её легко парсить в любом языке и любом фреймворке. Никаких числовых timestamps, никаких 2026-05-26 10:30:00 без T и Z.

Enum — строки, не числа

Enum в TypeScript по умолчанию числовой: OrderStatus.CONFIRMED равно 1. В JSON это выглядит как "status": 1, и клиенту непонятно, что это значит.

Чтобы в JSON попала читаемая строка, используют string enum — enum, где каждое значение явно задано строкой:

export enum OrderStatus {
  CREATED = 'CREATED',
  CONFIRMED = 'CONFIRMED',
  SHIPPED = 'SHIPPED',
  DELIVERED = 'DELIVERED',
  CANCELLED = 'CANCELLED',
}

export enum PaymentMethod {
  CREDIT_CARD = 'CREDIT_CARD',
  BANK_TRANSFER = 'BANK_TRANSFER',
  SBP = 'SBP',
}

Теперь в JSON попадёт "status": "CONFIRMED", а не "status": 1. Значения в UPPER_SNAKE_CASE — устойчивое соглашение: их сразу видно как константы, а не произвольные слова.

null и undefined в ответах

В TypeScript есть два способа сказать «нет значения»: null и undefined. Для ответов API они ведут себя по-разному при сериализации:

const order = { orderId: '123', status: 'CONFIRMED', comment: undefined };
JSON.stringify(order);
// → {"orderId":"123","status":"CONFIRMED"}
// comment отсутствует в JSON — это правильно

const order2 = { orderId: '123', status: 'CONFIRMED', comment: null };
JSON.stringify(order2);
// → {"orderId":"123","status":"CONFIRMED","comment":null}
// comment есть в JSON со значением null — это проблема

Поле null в успешном ответе ставит клиента в тупик: поле есть, но значения нет. Если поле необязательное — его не должно быть в ответе совсем. Для этого используют undefined через опциональное поле:

export class OrderResponse {
  orderId: string;
  status: OrderStatus;
  comment?: string;      // optional — если нет значения, поле исчезнет из JSON
}

При маппинге из доменного объекта, где null может присутствовать, конвертируют явно:

function mapToOrderResponse(order: Order): OrderResponse {
  return {
    orderId: order.id,
    status: order.status,
    comment: order.comment ?? undefined,  // null становится undefined
  };
}

Отдельный случай — PATCH-запрос. Там null в теле имеет особый смысл: «удали это поле». Это стандарт JSON Merge Patch (RFC 7396). Поэтому null разрешён только во входящем DTO, но не в ответе:

// Запрос PATCH — null допустим
export class UpdateOrderDto {
  @IsOptional()
  @IsString()
  comment?: string | null;     // null = команда удалить поле
}

// Ответ — null запрещён
export class OrderResponse {
  comment?: string;            // только undefined
}

Boolean-поля

Логические поля могут называться с префиксом или без — главное единообразие в проекте:

export class ProductResponse {
  productId: string;
  active: boolean;        // без префикса
  hasDiscount: boolean;   // с has
  canRefund: boolean;     // с can
}

Если в проекте уже есть сложившийся стиль — следуйте ему, не смешивайте.

Форматы ответов

Единичный объект

Один ресурс возвращается напрямую, без обёртки:

@Get(':id')
async findOne(@Param('id') id: string): Promise<OrderResponse> {
  return this.ordersService.findOne(id);
}

Обёртка { success: true, data: ... } — распространённая ошибка, она усложняет клиентский код без пользы: клиент вынужден каждый раз «разворачивать» ответ.

Коллекция с пагинацией

Коллекция возвращается в поле content, рядом — метаданные пагинации:

export class PagedOrdersResponse {
  content: OrderResponse[];
  page: number;
  size: number;
  totalElements: number;
  totalPages: number;
}

@Get()
async findAll(@Query() query: PaginationDto): Promise<PagedOrdersResponse> {
  return this.ordersService.findAll(query);
}
{
  "content": [{ "orderId": "abc", "status": "CONFIRMED" }],
  "page": 1,
  "size": 20,
  "totalElements": 243,
  "totalPages": 13
}

Если записей нет — content должен быть пустым массивом [], а не null.

Создание — 201 и Location

При успешном создании ресурса NestJS по умолчанию отдаёт статус 201. Хорошая практика — добавить заголовок Location с URL созданного ресурса:

@Post()
async create(
  @Body() dto: CreateOrderDto,
  @Res({ passthrough: true }) res: Response,
): Promise<OrderResponse> {
  const order = await this.ordersService.create(dto);
  res.location(`/api/v1/orders/${order.orderId}`);
  return order;
}

Клиент сразу знает, куда пойти за созданным ресурсом, не разбирая тело ответа.

Удаление — 204

При удалении тело не нужно, только статус 204. NestJS по умолчанию ставит 200, поэтому нужен явный @HttpCode:

@Delete(':id')
@HttpCode(204)
async remove(@Param('id') id: string): Promise<void> {
  await this.ordersService.remove(id);
}

Действия над ресурсом — 200

Эндпоинты-действия (confirm, cancel, publish) по соглашению отдают 200. Поскольку @Post по умолчанию отдаёт 201, нужен явный @HttpCode:

@Post(':id/confirm')
@HttpCode(200)
async confirm(@Param('id') id: string): Promise<OrderResponse> {
  return this.ordersService.confirm(id);
}

Коротко

  • camelCase — нативный формат TypeScript, поля попадают в JSON без настройки.
  • Date сериализуется в ISO 8601 автоматически при JSON.stringify.
  • Enum в JSON должен быть строкой: используй string enum с UPPER_SNAKE_CASE значениями.
  • В ответах (2xx) не должно быть null — необязательные поля делают optional (undefined).
  • null в теле PATCH допустим: это команда удалить поле (JSON Merge Patch).
  • Единичный ресурс — плоский объект без обёрток; коллекция — { content: [...] } + пагинация.
  • Пустая коллекция — [], не null.
  • Создание: 201 + заголовок Location. Удаление: 204 без тела. Действие: 200.

Что почитать дальше

  • Query-параметры и пагинация — как принять параметры страницы на входе.
  • Ошибки и RFC 9457 — формат ошибок vs формат успешных ответов.
  • Заголовки и трассировка — как работает Location и трассировочные заголовки.