JSON in NestJS: DTOs, Interceptors, and Validation

Last updated:

NestJS is an opinionated Node.js framework built on TypeScript that combines decorators, dependency injection, and a modular architecture to make JSON API development structured and maintainable. Rather than hand-wiring body parsing, validation, and error formatting, NestJS provides a layered pipeline — pipes, interceptors, guards, and filters — that transforms JSON at each stage of the request/response lifecycle. This guide covers the full picture, from DTOs and validation through response serialization, custom interceptors, exception handling, and OpenAPI documentation.

Controllers and JSON Responses

In NestJS, a controller class decorated with @Controller() maps HTTP routes to handler methods. When a handler returns a plain JavaScript object or class instance, NestJS serializes it to JSON automatically — you never call JSON.stringify() or res.json() yourself.

// users.controller.ts
import { Controller, Get, Post, Body, Param, Delete } from '@nestjs/common'
import { UsersService } from './users.service'
import { CreateUserDto } from './dto/create-user.dto'

@Controller('users')
export class UsersController {
  constructor(private readonly usersService: UsersService) {}

  // GET /users → returns JSON array automatically
  @Get()
  findAll() {
    return this.usersService.findAll()
    // NestJS calls JSON.stringify() → Content-Type: application/json
  }

  // GET /users/:id → returns single JSON object
  @Get(':id')
  findOne(@Param('id') id: string) {
    return this.usersService.findOne(+id)
  }

  // POST /users → parses JSON body, returns created object
  @Post()
  create(@Body() createUserDto: CreateUserDto) {
    return this.usersService.create(createUserDto)
  }

  // DELETE /users/:id → 200 with JSON or void (204)
  @Delete(':id')
  remove(@Param('id') id: string) {
    return this.usersService.remove(+id)
  }
}

To set a specific HTTP status code on a successful response, use the @HttpCode() decorator. For full control over the response (headers, streaming), inject the underlying @Res() object — but note that doing so disables NestJS's automatic JSON serialization.

import { HttpCode, HttpStatus } from '@nestjs/common'

@Post()
@HttpCode(HttpStatus.CREATED)   // 201 instead of default 200
create(@Body() dto: CreateUserDto) {
  return this.usersService.create(dto)
}

// Async handlers work identically — NestJS awaits the Promise
@Get(':id')
async findOne(@Param('id') id: string) {
  const user = await this.usersService.findOne(+id)
  return user   // serialized to JSON automatically
}

Request DTOs with class-validator

A DTO (Data Transfer Object) is a TypeScript class that describes the expected shape of an incoming JSON body. Unlike interfaces, DTO classes survive compilation and support decorators from class-validator that run at runtime. This makes them the foundation of NestJS request validation.

// dto/create-user.dto.ts
import {
  IsString, IsEmail, IsInt, IsOptional,
  MinLength, MaxLength, Min, Max, IsEnum,
} from 'class-validator'
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'

export enum UserRole {
  Admin = 'admin',
  User = 'user',
  Guest = 'guest',
}

export class CreateUserDto {
  @ApiProperty({ example: 'Alice', description: 'Display name' })
  @IsString()
  @MinLength(2)
  @MaxLength(50)
  name: string

  @ApiProperty({ example: 'alice@example.com' })
  @IsEmail()
  email: string

  @ApiProperty({ minimum: 18, maximum: 120 })
  @IsInt()
  @Min(18)
  @Max(120)
  age: number

  @ApiPropertyOptional({ enum: UserRole, default: UserRole.User })
  @IsOptional()
  @IsEnum(UserRole)
  role?: UserRole = UserRole.User
}

// dto/update-user.dto.ts — PartialType makes all fields optional
import { PartialType } from '@nestjs/swagger'
export class UpdateUserDto extends PartialType(CreateUserDto) {}

PartialType from @nestjs/swagger (or @nestjs/mapped-types) generates a new class with all properties optional, preserving all class-validator decorators. This avoids duplicating validation logic between create and update DTOs.

ValidationPipe Configuration

ValidationPipe is the NestJS pipe that runs class-validator against incoming DTOs. Register it globally in main.ts so every route is validated without per-route setup.

// main.ts
import { NestFactory } from '@nestjs/core'
import { ValidationPipe } from '@nestjs/common'
import { AppModule } from './app.module'

async function bootstrap() {
  const app = NestFactory.create(AppModule)

  app.useGlobalPipes(
    new ValidationPipe({
      whitelist: true,            // strip properties not in DTO
      forbidNonWhitelisted: true, // 400 if unknown properties sent
      transform: true,            // convert plain JSON to DTO instances
      transformOptions: {
        enableImplicitConversion: true, // coerce query param strings to numbers/booleans
      },
    }),
  )

  await app.listen(3000)
}
bootstrap()

When validation fails, ValidationPipe throws a BadRequestException with a message array listing every failing constraint. The default error response looks like this:

// POST /users with { "name": "A", "email": "not-an-email", "age": 15 }
// Response: 400 Bad Request
{
  "statusCode": 400,
  "message": [
    "name must be longer than or equal to 2 characters",
    "email must be an email",
    "age must not be less than 18"
  ],
  "error": "Bad Request"
}

// Customize with exceptionFactory
new ValidationPipe({
  whitelist: true,
  exceptionFactory: (errors) => {
    const messages = errors.map(e => ({
      field: e.property,
      errors: Object.values(e.constraints ?? {}),
    }))
    return new BadRequestException({ statusCode: 400, errors: messages })
  },
})

Response Serialization with class-transformer

ClassSerializerInterceptor intercepts the controller's return value and runs class-transformer's instanceToPlain() on it before JSON serialization. This lets you control which fields are included in the response using decorators on your entity or response class.

// user.entity.ts
import { Exclude, Expose, Transform } from 'class-transformer'

export class UserEntity {
  @Expose()
  id: number

  @Expose()
  name: string

  @Expose()
  email: string

  @Exclude()           // never appears in JSON response
  password: string

  @Exclude()
  refreshToken: string

  @Expose()
  @Transform(({ value }) => value.toISOString())
  createdAt: Date

  constructor(partial: Partial<UserEntity>) {
    Object.assign(this, partial)
  }
}

// users.controller.ts
import { UseInterceptors, ClassSerializerInterceptor } from '@nestjs/common'
import { SerializeOptions } from '@nestjs/common'

@Controller('users')
@UseInterceptors(ClassSerializerInterceptor)
export class UsersController {
  @Get(':id')
  async findOne(@Param('id') id: string) {
    const rawUser = await this.usersService.findOne(+id)
    // Wrap in entity class so @Exclude/@Expose decorators apply
    return new UserEntity(rawUser)
  }
}

// Or register globally in main.ts
import { Reflector } from '@nestjs/core'
app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector)))

Use @SerializeOptions({ strategy: 'excludeAll' } ) on a controller or method to flip the default — all properties are hidden unless explicitly marked @Expose(). This is safer for entities with many sensitive fields.

Custom Interceptors for JSON Transformation

Beyond class-transformer serialization, you can write custom interceptors to wrap every response in a standard envelope, add metadata, or transform the data shape. Implement NestInterceptor and use RxJS operators in the intercept() method.

// interceptors/response-wrap.interceptor.ts
import {
  Injectable, NestInterceptor, ExecutionContext, CallHandler,
} from '@nestjs/common'
import { Observable } from 'rxjs'
import { map } from 'rxjs/operators'

export interface ApiResponse<T> {
  success: boolean
  data: T
  timestamp: string
}

@Injectable()
export class ResponseWrapInterceptor<T>
  implements NestInterceptor<T, ApiResponse<T>> {
  intercept(
    context: ExecutionContext,
    next: CallHandler,
  ): Observable<ApiResponse<T>> {
    return next.handle().pipe(
      map((data) => ({
        success: true,
        data,
        timestamp: new Date().toISOString(),
      })),
    )
  }
}

// main.ts — register globally
app.useGlobalInterceptors(new ResponseWrapInterceptor())

// All successful responses now look like:
// {
//   "success": true,
//   "data": { "id": 1, "name": "Alice", "email": "alice@example.com" },
//   "timestamp": "2026-05-19T10:00:00.000Z"
// }

For paginated responses, create a dedicated interceptor that reads pagination metadata from the execution context or the returned data object and injects page, total, and limit fields alongside the items array.

// Interceptor that adds request duration to every response
@Injectable()
export class TimingInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const start = Date.now()
    return next.handle().pipe(
      map((data) => ({
        ...data,
        _meta: { durationMs: Date.now() - start },
      })),
    )
  }
}

// Interceptors run in order of registration
// global → controller-level → method-level

Exception Filters for JSON Errors

NestJS's built-in global exception filter catches all HttpException instances and returns a consistent JSON error. To customize the error format, implement ExceptionFilter and bind it globally.

// filters/http-exception.filter.ts
import {
  ExceptionFilter, Catch, ArgumentsHost,
  HttpException, HttpStatus, Logger,
} from '@nestjs/common'
import { Request, Response } from 'express'

@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
  private readonly logger = new Logger(HttpExceptionFilter.name)

  catch(exception: HttpException, host: ArgumentsHost) {
    const ctx = host.switchToHttp()
    const response = ctx.getResponse<Response>()
    const request = ctx.getRequest<Request>()
    const status = exception.getStatus()
    const exceptionResponse = exception.getResponse()

    const error =
      typeof exceptionResponse === 'string'
        ? { message: exceptionResponse }
        : (exceptionResponse as object)

    const body = {
      statusCode: status,
      timestamp: new Date().toISOString(),
      path: request.url,
      method: request.method,
      ...error,
    }

    this.logger.warn(`${request.method} ${request.url} → ${status}`)
    response.status(status).json(body)
  }
}

// Catch-all filter for unexpected errors
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
  private readonly logger = new Logger(AllExceptionsFilter.name)

  catch(exception: unknown, host: ArgumentsHost) {
    const ctx = host.switchToHttp()
    const response = ctx.getResponse<Response>()

    const status =
      exception instanceof HttpException
        ? exception.getStatus()
        : HttpStatus.INTERNAL_SERVER_ERROR

    this.logger.error('Unhandled exception', exception)

    response.status(status).json({
      statusCode: status,
      message: 'Internal server error',
      timestamp: new Date().toISOString(),
    })
  }
}

// main.ts
app.useGlobalFilters(new AllExceptionsFilter(), new HttpExceptionFilter())
// Filters are applied in reverse order: most specific last wins

Throw built-in NestJS HTTP exceptions from services and controllers — they are automatically caught by the filter layer:

import {
  NotFoundException, BadRequestException,
  UnauthorizedException, ForbiddenException,
  ConflictException,
} from '@nestjs/common'

// In a service
async findOne(id: number): Promise<UserEntity> {
  const user = await this.usersRepository.findOne({ where: { id } })
  if (!user) {
    throw new NotFoundException(`User #${id} not found`)
  }
  return user
}

async create(dto: CreateUserDto): Promise<UserEntity> {
  const exists = await this.usersRepository.findOne({ where: { email: dto.email } })
  if (exists) {
    throw new ConflictException('Email already registered')
  }
  return this.usersRepository.save(dto)
}

Swagger / OpenAPI JSON Documentation

@nestjs/swagger reads decorators at runtime and generates an OpenAPI 3.0 specification from your controllers and DTOs — no separate schema file or YAML to maintain.

// Install: npm install @nestjs/swagger swagger-ui-express

// main.ts
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'

async function bootstrap() {
  const app = await NestFactory.create(AppModule)

  const config = new DocumentBuilder()
    .setTitle('Users API')
    .setDescription('NestJS JSON API example')
    .setVersion('1.0')
    .addBearerAuth()
    .build()

  const document = SwaggerModule.createDocument(app, config)
  SwaggerModule.setup('api', app, document)
  // Swagger UI:  http://localhost:3000/api
  // OpenAPI JSON: http://localhost:3000/api-json

  await app.listen(3000)
}

Decorate controllers and methods with Swagger decorators to enrich the generated spec:

import {
  ApiTags, ApiOperation, ApiResponse,
  ApiBearerAuth, ApiParam,
} from '@nestjs/swagger'

@ApiTags('users')
@ApiBearerAuth()
@Controller('users')
export class UsersController {
  @Post()
  @ApiOperation({ summary: 'Create a new user' })
  @ApiResponse({ status: 201, description: 'User created', type: UserEntity })
  @ApiResponse({ status: 400, description: 'Validation failed' })
  @ApiResponse({ status: 409, description: 'Email already exists' })
  create(@Body() dto: CreateUserDto) {
    return this.usersService.create(dto)
  }

  @Get(':id')
  @ApiParam({ name: 'id', type: Number, description: 'User ID' })
  @ApiResponse({ status: 200, type: UserEntity })
  @ApiResponse({ status: 404, description: 'User not found' })
  findOne(@Param('id') id: string) {
    return this.usersService.findOne(+id)
  }
}

// DTO with Swagger decorators (combine with class-validator)
export class CreateUserDto {
  @ApiProperty({ example: 'Alice', minLength: 2, maxLength: 50 })
  @IsString()
  @MinLength(2)
  name: string

  @ApiProperty({ example: 'alice@example.com', format: 'email' })
  @IsEmail()
  email: string
}

The raw OpenAPI JSON at /api-json can be consumed by client code generators (e.g., openapi-typescript, @hey-api/openapi-ts) to generate typed API clients automatically.

Key Definitions

DTO (Data Transfer Object)
A TypeScript class that defines the shape of data entering or leaving a NestJS endpoint. DTOs use class-validator decorators for runtime validation and class-transformer decorators for serialization; they also drive Swagger schema generation via @nestjs/swagger.
ValidationPipe
A built-in NestJS pipe that validates incoming request data against a DTO class using class-validator. With whitelist: true, it strips extra properties; with transform: true, it converts plain objects to DTO instances and coerces primitive types.
Interceptor
A class implementing NestInterceptor that wraps the route handler execution using RxJS Observables. Interceptors can transform the response, add metadata, measure timing, or cache results. They run after guards but before exception filters on the return path.
Exception Filter
A class implementing ExceptionFilter that catches thrown exceptions and converts them to JSON HTTP responses. The @Catch() decorator specifies which exception types the filter handles. Filters are the last layer in the NestJS request pipeline.
class-transformer
A TypeScript library that transforms plain JavaScript objects to typed class instances (plainToClass) and back (instanceToPlain). NestJS's ClassSerializerInterceptor uses class-transformer to apply @Exclude(), @Expose(), and @Transform() decorators during response serialization.

FAQ

How does NestJS serialize controller return values to JSON?

NestJS controllers return plain JavaScript objects or class instances — the framework automatically calls JSON.stringify() on the return value via its built-in serialization layer. When you return a plain object, NestJS sets Content-Type: application/json and serializes it. When you return a class instance decorated with class-transformer decorators, the ClassSerializerInterceptor runs instanceToPlain() before serialization, applying all @Exclude() and @Expose() rules. You never call JSON.stringify() manually in a NestJS controller.

What is a DTO in NestJS and why do I need one?

A DTO (Data Transfer Object) is a TypeScript class defining the shape of data flowing into an endpoint. Unlike interfaces, classes survive TypeScript compilation and support runtime decorators from class-validator and class-transformer. When ValidationPipe is enabled, NestJS instantiates the DTO class from the raw request body and runs all validation decorators, rejecting the request with a 400 error if any constraint fails. DTOs also drive Swagger schema generation via @ApiProperty() decorators. Without DTOs, you lose runtime validation, documentation, and type safety simultaneously.

What does ValidationPipe whitelist option do?

whitelist: true strips any properties from the incoming request body not declared in the DTO class, preventing over-posting attacks where a client sends extra fields like isAdmin: true that could be inadvertently saved. Extra properties are silently removed. Adding forbidNonWhitelisted: true causes NestJS to reject the request with a 400 error when unknown properties are detected. The recommended production configuration is { whitelist: true, forbidNonWhitelisted: true, transform: true }.

How does ClassSerializerInterceptor work with @Exclude and @Expose?

ClassSerializerInterceptor intercepts the response after the controller returns and runs class-transformer's instanceToPlain(). Decorate entity fields with @Exclude() to hide them (e.g., password, refreshToken) and @Expose() to explicitly include them. The controller must return class instances — not plain objects — for decorators to take effect. Register it globally in main.ts with app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector))).

How do I create a custom interceptor to wrap all JSON responses?

Implement NestInterceptor with an intercept() method that uses RxJS to transform the response stream. Call next.handle() to get an Observable of the response, then pipe it through map(data => ({ success: true, data, timestamp: new Date().toISOString() })) to wrap it. Register globally with app.useGlobalInterceptors(new ResponseWrapInterceptor()). Note that exception filters handle error responses separately — the interceptor only transforms successful responses.

How do NestJS exception filters format JSON error responses?

NestJS's built-in filter catches HttpException instances and returns a JSON envelope with statusCode, message, and error. For custom formats, implement ExceptionFilter with a catch() method, access the response via context.switchToHttp().getResponse(), and call response.status(status).json({ ... }). Throw built-in exceptions (NotFoundException, BadRequestException, etc.) from services and controllers — they are automatically caught and formatted.

How do I set up Swagger JSON documentation in NestJS?

Install @nestjs/swagger and swagger-ui-express. In main.ts, create a DocumentBuilder config, call SwaggerModule.createDocument(app, config), then SwaggerModule.setup('api', app, document). The Swagger UI serves at /api and the raw OpenAPI JSON at /api-json. Decorate DTOs with @ApiProperty() and controllers with @ApiTags(), @ApiOperation(), and @ApiResponse(). The spec reflects actual runtime decorators and can be consumed by client code generators.

How do I handle JSON body parsing errors in NestJS?

Malformed JSON (syntax errors, invalid Unicode) is caught by the underlying HTTP adapter before reaching NestJS route handlers, resulting in a 400 Bad Request. In Express mode, add error-handling middleware to customize this: catch SyntaxError with err.status === 400 and return your custom JSON shape. ValidationPipe handles structurally valid JSON that fails DTO validation — it returns a 400 with a message array listing every failing constraint. Configure exceptionFactory on ValidationPipe to customize the validation error response shape.

Further reading and primary sources