When a client receives a response from an API, it sees JSON. What the fields look like, how the dates are written, what happens to empty values — all of this matters for compatibility. In NestJS, most of the rules apply on their own: TypeScript already writes fields in camelCase, and Date turns into ISO 8601 on any JSON.stringify. You only need to deal with a few nuances that require an explicit decision.
Field names
In Java and Python it's common to name fields with an underscore: customer_id, created_at. For a REST API from a Java service, you often need to configure a mapper so that the fields in JSON are in camelCase.
In TypeScript and NestJS there's no such problem: class fields are already written in camelCase, and they end up in JSON without any additional configuration.
export class OrderResponse {
orderId: string;
customerId: string;
createdAt: Date; // → "2026-05-26T10:30:00Z" in JSON
totalAmount: number;
status: OrderStatus;
items: OrderItemResponse[];
}
A few naming conventions:
- Identifiers are named with the
Idsuffix:orderId,customerId, not justid. - Date fields are named with the
Atsuffix:createdAt,updatedAt. - Collection fields are plural:
items,tags.
A common mistake is to write customer_id or created_at in snake_case. Such a field ends up in JSON as is, and the client gets an unexpected name.
Dates and time
A Date object in TypeScript turns into an ISO 8601 string automatically when serialized to JSON:
const date = new Date('2026-05-26T10:30:00Z');
JSON.stringify({ createdAt: date });
// → {"createdAt":"2026-05-26T10:30:00.000Z"}
The client always gets a standard string — it's easy to parse in any language and any framework. No numeric timestamps, no 2026-05-26 10:30:00 without a T and Z.
Enum — strings, not numbers
An enum in TypeScript is numeric by default: OrderStatus.CONFIRMED equals 1. In JSON this looks like "status": 1, and the client can't tell what it means.
To get a readable string into JSON, you use a string enum — an enum where each value is explicitly set as a string:
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',
}
Now "status": "CONFIRMED" ends up in JSON, not "status": 1. Values in UPPER_SNAKE_CASE are a solid convention: they're immediately recognizable as constants rather than arbitrary words.
null and undefined in responses
TypeScript has two ways to say "no value": null and undefined. For API responses they behave differently during serialization:
const order = { orderId: '123', status: 'CONFIRMED', comment: undefined };
JSON.stringify(order);
// → {"orderId":"123","status":"CONFIRMED"}
// comment is absent from the JSON — this is correct
const order2 = { orderId: '123', status: 'CONFIRMED', comment: null };
JSON.stringify(order2);
// → {"orderId":"123","status":"CONFIRMED","comment":null}
// comment is present in the JSON with a null value — this is a problem
A null field in a successful response puzzles the client: the field is there, but there's no value. If a field is optional, it shouldn't be in the response at all. For this you use undefined via an optional field:
export class OrderResponse {
orderId: string;
status: OrderStatus;
comment?: string; // optional — if there's no value, the field disappears from the JSON
}
When mapping from a domain object where null may be present, you convert it explicitly:
function mapToOrderResponse(order: Order): OrderResponse {
return {
orderId: order.id,
status: order.status,
comment: order.comment ?? undefined, // null becomes undefined
};
}
A separate case is the PATCH request. There null in the body has a special meaning: "delete this field". This is the JSON Merge Patch standard (RFC 7396). So null is allowed only in the incoming DTO, but not in the response:
// PATCH request — null is allowed
export class UpdateOrderDto {
@IsOptional()
@IsString()
comment?: string | null; // null = command to delete the field
}
// Response — null is forbidden
export class OrderResponse {
comment?: string; // undefined only
}
Boolean fields
Boolean fields can be named with or without a prefix — the main thing is consistency within the project:
export class ProductResponse {
productId: string;
active: boolean; // without a prefix
hasDiscount: boolean; // with has
canRefund: boolean; // with can
}
If the project already has an established style, follow it — don't mix.
Response formats
A single object
A single resource is returned directly, without a wrapper:
@Get(':id')
async findOne(@Param('id') id: string): Promise<OrderResponse> {
return this.ordersService.findOne(id);
}
The { success: true, data: ... } wrapper is a common mistake — it complicates client code without benefit: the client has to "unwrap" the response every time.
A collection with pagination
A collection is returned in the content field, with pagination metadata alongside:
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
}
If there are no records, content must be an empty array [], not null.
Creation — 201 and Location
On successful creation of a resource, NestJS returns status 201 by default. A good practice is to add a Location header with the URL of the created resource:
@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;
}
The client immediately knows where to go for the created resource without parsing the response body.
Deletion — 204
On deletion, no body is needed, only status 204. NestJS returns 200 by default, so an explicit @HttpCode is required:
@Delete(':id')
@HttpCode(204)
async remove(@Param('id') id: string): Promise<void> {
await this.ordersService.remove(id);
}
Actions on a resource — 200
Action endpoints (confirm, cancel, publish) return 200 by convention. Since @Post returns 201 by default, an explicit @HttpCode is required:
@Post(':id/confirm')
@HttpCode(200)
async confirm(@Param('id') id: string): Promise<OrderResponse> {
return this.ordersService.confirm(id);
}
In short
- camelCase is the native TypeScript format; fields end up in JSON without configuration.
Dateserializes to ISO 8601 automatically onJSON.stringify.- An enum in JSON should be a string: use a string enum with UPPER_SNAKE_CASE values.
- Responses (2xx) shouldn't contain
null— make optional fieldsoptional(undefined). nullis allowed in a PATCH body: it's a command to delete a field (JSON Merge Patch).- A single resource is a flat object without wrappers; a collection is
{ content: [...] }+ pagination. - An empty collection is
[], notnull. - Creation: 201 + a
Locationheader. Deletion: 204 without a body. Action: 200.
Further reading
- Query parameters and pagination — how to accept page parameters on input.
- Errors and RFC 9457 — the error format vs the format of successful responses.
- Headers and tracing — how
Locationand tracing headers work.