← Back to the section

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 Id suffix: orderId, customerId, not just id.
  • Date fields are named with the At suffix: 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.
  • Date serializes to ISO 8601 automatically on JSON.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 fields optional (undefined).
  • null is 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 [], not null.
  • Creation: 201 + a Location header. 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 Location and tracing headers work.