Когда клиент получает ответ от 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и трассировочные заголовки.