Опирается на правила:
R-FLD-1..7,R-RSP-1..8и X-коды из REST API Style Guide → раздел JSON и формат ответов.
Важно знать
- camelCase нативен для TypeScript — поля TS-класса = поля JSON без настройки.
Dateсериализуется в ISO 8601 приJSON.stringifyавтоматически.- Enum —
string enumс UPPER_SNAKE_CASE значениями ('CONFIRMED', не0).nullв 2xx запрещён — незаполненное поле должно бытьundefined(выпадает из JSON).nullв PATCH body — команда удалить поле (JSON Merge Patch RFC 7396).- Envelope (
{ success: true, data: ... }) — запрещён.- Коллекция —
{ "content": [...] }+ метаданные пагинации (не envelope).- Пустые коллекции —
[], неnull.
NestJS с TypeScript делает большинство правил следствием языка: camelCase полей, ISO 8601 для Date, UPPER_SNAKE_CASE через string enum. Нужно явно позаботиться только об исключении null из ответов.
Имена полей
R-FLD-1..5:
export class OrderResponse {
orderId: string; // ✓ camelCase + Id-суффикс
customerId: string; // ✓
createdAt: Date; // ✓ → "2026-05-26T10:30:00Z" в JSON
totalAmount: number; // ✓ camelCase
status: OrderStatus; // ✓ enum UPPER_SNAKE_CASE
items: OrderItemResponse[]; // ✓ коллекция — множественное число
}
// ✗ антипаттерны именования
customer_id: string; // snake_case — R-FLD-1
created_at: Date; // snake_case — R-FLD-2
status: string; // raw string вместо enum — R-FLD-3
id: string; // без суффикса Id — R-FLD-5
Enum — UPPER_SNAKE_CASE
R-FLD-3:
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',
}
String enum гарантирует корректный JSON без дополнительных трансформеров.
null → undefined
R-RSP-X1: null в 2xx запрещён. Незаполненное поле — undefined, не null.
export class OrderResponse {
orderId: string;
status: OrderStatus;
comment?: string; // ✓ optional — undefined, если не заполнено
// comment: string | null // ✗ null попадёт в JSON
}
undefined выпадает из JSON при сериализации. null — остаётся. Разница критична:
const order = { orderId: '123', status: 'CONFIRMED', comment: undefined };
JSON.stringify(order);
// → {"orderId":"123","status":"CONFIRMED"} ✓ comment отсутствует
const order2 = { orderId: '123', status: 'CONFIRMED', comment: null };
JSON.stringify(order2);
// → {"orderId":"123","status":"CONFIRMED","comment":null} ✗ нарушение R-RSP-X1
При маппинге из доменного объекта:
function mapToOrderResponse(order: Order): OrderResponse {
return {
orderId: order.id,
status: order.status,
comment: order.comment ?? undefined, // null → undefined
};
}
null в PATCH body — удалить поле
R-FLD-6: семантика запроса, не ответа.
PATCH /api/v1/orders/:id
Content-Type: application/merge-patch+json
{ "comment": null }
null в теле PATCH — команда удалить поле comment. Это JSON Merge Patch (RFC 7396).
export class UpdateOrderDto {
@IsOptional()
@IsString()
comment?: string | null; // null допустим в request-DTO
}
Разрешить null только в request-DTO (PATCH body). В response-DTO — никогда.
Boolean
R-FLD-7: префикс is/has/can опционально, но единообразно.
export class ProductResponse {
productId: string;
active: boolean; // ✓ без префикса
hasDiscount: boolean; // ✓ с префиксом has
canRefund: boolean; // ✓ с префиксом can
}
Главное — единый стиль в проекте.
Форматы ответов
R-RSP-1..6:
Единичный ресурс
@Get(':id')
async findOne(@Param('id') id: string): Promise<OrderResponse> {
return this.ordersService.findOne(id);
}
Без envelope. OrderResponse — сам ресурс.
Коллекция — 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": "...", "status": "CONFIRMED" }],
"page": 1,
"size": 20,
"totalElements": 243,
"totalPages": 13
}
Создание — 201 + Location
R-RSP-3:
@Post()
@ApiOperation({ operationId: 'createOrder', summary: 'Create order' })
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;
}
NestJS с @Post по умолчанию отдаёт 201. res.location(...) добавляет заголовок Location.
Удаление — 204
R-RSP-5:
@Delete(':id')
@HttpCode(204) // явный @HttpCode обязателен
async remove(@Param('id') id: string): Promise<void> {
await this.ordersService.remove(id);
}
Action — 200
R-RSP-6:
@Post(':id/confirm')
@HttpCode(200) // явный @HttpCode обязателен (дефолт @Post — 201)
async confirm(@Param('id') id: string): Promise<OrderResponse> {
return this.ordersService.confirm(id);
}
Пустые коллекции
R-RSP-7:
export class ProductResponse {
productId: string;
tags: string[] = []; // ✓ пустой массив, не null
}
// При маппинге
tags: product.tags ?? [], // null → []
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
comment: string \| null = null в response | R-RSP-X1 | comment?: string (undefined) |
"" пустая строка вместо отсутствия | R-RSP-X2 | поле отсутствует (undefined) |
nullable: true в @ApiProperty | R-RSP-X3 | optional field |
{ success: true, data: order } envelope | R-RSP-X4 | плоский OrderResponse |
customer_id snake_case | R-FLD-1 | customerId |
status: 'in_progress' строчные | R-FLD-3 | 'IN_PROGRESS' |
id: string без суффикса | R-FLD-5 | orderId, productId |
tags: null | R-RSP-7 | tags: [] |
2026-05-26 10:30:00 без T и Z | R-FLD-2 | 2026-05-26T10:30:00Z |
Куда дальше
- Query-параметры — структура
contentдля коллекций. - Ошибки RFC 9457 — формат error response vs success.
- Заголовки и трассировка —
Locationдля 201. - Версионирование — добавление optional поля non-breaking.
- REST API Style Guide (нормативно) — формулировки.