Introduction

An API should never assume that the data received from a client is correct. A user might submit an invalid email address, send a negative product price, leave a required field empty, or pass an unexpected value in a request parameter. If the application accepts this data without checking it, problems can appear later in database operations or business logic.

Consider a user registration API. The endpoint expects a name, email address, and password. A mobile application might send all three fields correctly, but another client could send an empty name, an invalid email, or a password that does not meet the application's requirements.

Request validation helps catch these problems before the request reaches the service layer.

NestJS provides pipes for validating incoming request data. Existing applications commonly use ValidationPipe with class-validator and class-transformer. NestJS 12 also supports Standard Schema-compatible libraries through StandardSchemaValidationPipe, allowing developers to use tools such as Zod, Valibot, and ArkType.

This article explains how to add request validation to a NestJS 12 application, implement both validation approaches, return useful error responses, and apply validation consistently across API endpoints.

Understanding Request Validation in NestJS

When a client sends an HTTP request, NestJS processes it through several stages before calling the controller method.

A simplified request flow looks like this:

Code
Client Request
      |
      v
Middleware
      |
      v
Guards
      |
      v
Interceptors
      |
      v
Pipes
      |
      v
Controller
      |
      v
Service
      |
      v
Database

Pipes are responsible for transforming or validating values before they reach the controller.

For example, consider an endpoint that receives a user ID:

TypeScript
@Get(':id')
findOne(@Param('id') id: number) {
  return this.usersService.findOne(id);
}

HTTP route parameters arrive as strings. A request such as /users/25 initially provides the value "25", not the number 25.

A pipe can validate and convert this value before the controller uses it.

NestJS provides several built-in pipes, including:

  • ValidationPipe for class-based validation.
  • StandardSchemaValidationPipe for Standard Schema-compatible validation.
  • ParseIntPipe for integer conversion and validation.
  • ParseBoolPipe for boolean conversion and validation.
  • ParseUUIDPipe for UUID validation.

Choosing the right pipe depends on the type of request data and the validation approach used by the application.

Setting Up a NestJS 12 Application

For this example, we will build a user registration endpoint.

The API will accept the following information:

JSON
{
  "name": "Rahul Sharma",
  "email": "[email protected]",
  "password": "StrongPass123"
}

The application should reject requests when:

  • The name is missing or empty.
  • The email address is invalid.
  • The password is shorter than eight characters.
  • The request contains unexpected properties.

We will first implement this using the familiar class-based validation approach and then introduce Standard Schema validation.

For a NestJS application using npm, install the required packages:

Bash
npm install class-validator class-transformer

The class-validator package provides validation decorators, while class-transformer helps convert plain request objects into class instances.

Implementing Request Validation With ValidationPipe

The class-based approach is useful when an existing application already uses DTO classes.

Creating a Request DTO

Create a file named create-user.dto.ts.

TypeScript
import {
  IsEmail,
  IsNotEmpty,
  IsString,
  MinLength
} from 'class-validator';

export class CreateUserDto {
  @IsString()
  @IsNotEmpty()
  name: string;

  @IsEmail()
  email: string;

  @IsString()
  @MinLength(8)
  password: string;
}

Each decorator defines a rule for a particular property.

IsString() ensures that the value is a string. IsNotEmpty() prevents an empty name. IsEmail() checks the email format, and MinLength(8) requires the password to contain at least eight characters.

Keeping these rules inside a DTO makes the request structure easier to understand and reuse.

Registering a Global Validation Pipe

Creating a DTO does not automatically validate every incoming request. The application must register a validation pipe.

Open main.ts and configure the pipe during application startup:

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

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

  app.useGlobalPipes(
    new ValidationPipe({
      whitelist: true,
      forbidNonWhitelisted: true,
      transform: true
    })
  );

  await app.listen(process.env.PORT ?? 3000);
}

bootstrap();

These three options are important.

whitelist: true removes properties that do not have validation decorators in the DTO.

forbidNonWhitelisted: true changes that behavior by rejecting requests containing unexpected properties instead of silently removing them.

transform: true allows NestJS to transform incoming plain objects into their DTO classes and perform supported primitive conversions.

For example, if a client sends an additional isAdmin property, the request will be rejected rather than allowing an unexpected field to pass into the application.

This is particularly useful for preventing clients from submitting fields that should only be controlled by the server.

Creating the User Registration Endpoint

Now that the DTO and global validation pipe are ready, create the controller.

TypeScript
import {
  Body,
  Controller,
  Post
} from '@nestjs/common';

import { CreateUserDto } from './dto/create-user.dto';

@Controller('users')
export class UsersController {

  @Post()
  create(@Body() createUserDto: CreateUserDto) {
    return {
      message: 'User registration request received',
      user: {
        name: createUserDto.name,
        email: createUserDto.email
      }
    };
  }
}

The controller receives a validated CreateUserDto object. The password is deliberately excluded from the response.

In a real application, the controller should pass the DTO to a service that handles password hashing, duplicate email checks, and database persistence. Request validation checks the shape and basic rules of the input; it does not replace business validation.

For example, checking whether an email address is already registered belongs in the service or database layer, not in a simple DTO decorator.

Testing the Endpoint

Start the application:

Bash
npm run start:dev

Send a valid request:

Code
POST /users
Content-Type: application/json
JSON
{
  "name": "Rahul Sharma",
  "email": "[email protected]",
  "password": "StrongPass123"
}

The controller should receive the validated data.

Now try an invalid request:

JSON
{
  "name": "",
  "email": "invalid-email",
  "password": "123",
  "isAdmin": true
}

NestJS rejects the request before the controller method executes. The response contains validation errors explaining which fields failed.

This is the main benefit of using a global validation pipe: developers do not need to repeat the same validation logic in every controller method.

Using Standard Schema Validation in NestJS 12

NestJS 12 also supports Standard Schema-compatible validation libraries.

This approach is useful when a project already uses Zod, Valibot, or ArkType to define data structures.

Instead of defining validation rules through decorators, developers define a schema and infer the TypeScript type from it.

For this example, we will use Zod.

Install the package:

Bash
npm install zod

Creating a Zod Schema

Create a file named create-user.schema.ts.

TypeScript
import { z } from 'zod';

export const createUserSchema = z.object({
  name: z.string().trim().min(1, 'Name is required'),

  email: z.string().email('Enter a valid email address'),

  password: z.string().min(
    8,
    'Password must contain at least 8 characters'
  )
});

export type CreateUserDto =
  z.infer<typeof createUserSchema>;

The schema describes the expected request structure.

The trim() method removes surrounding whitespace before the name is checked. The min() rule prevents an empty name, while the email and password rules validate their respective fields.

The TypeScript type is generated from the schema:

TypeScript
export type CreateUserDto =
  z.infer<typeof createUserSchema>;

This avoids maintaining separate validation rules and TypeScript interfaces for the same request.

If the schema changes, the inferred type changes with it.

Registering StandardSchemaValidationPipe

The next step is registering the Standard Schema validation pipe.

In main.ts, add the following configuration:

TypeScript
import { NestFactory } from '@nestjs/core';
import {
  StandardSchemaValidationPipe
} from '@nestjs/common';

import { AppModule } from './app.module';

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

  app.useGlobalPipes(
    new StandardSchemaValidationPipe()
  );

  await app.listen(process.env.PORT ?? 3000);
}

bootstrap();

This pipe validates request parameters that declare a Standard Schema-compatible schema.

Unlike the class-based ValidationPipe, it does not depend on decorator metadata or DTO class instances.

Applying the Schema to a Controller

Update the controller to attach the schema to the request body:

TypeScript
import {
  Body,
  Controller,
  Post
} from '@nestjs/common';

import {
  createUserSchema,
  CreateUserDto
} from './create-user.schema';

@Controller('users')
export class UsersController {

  @Post()
  create(
    @Body({ schema: createUserSchema })
    user: CreateUserDto
  ) {
    return {
      message: 'User registration request received',
      user: {
        name: user.name,
        email: user.email
      }
    };
  }
}

The schema option connects the request body to the Zod validation rules.

The global StandardSchemaValidationPipe reads that metadata and validates the incoming value before the controller executes.

The controller receives the inferred CreateUserDto type, so developers get TypeScript support without creating a separate DTO class.

Choosing Between ValidationPipe and StandardSchemaValidationPipe

Both approaches are supported. The right choice depends on the existing project structure and how the team prefers to define validation rules.

ComparisonValidationPipeStandardSchemaValidationPipe
Validation rulesClass decoratorsSchema definitions
Common librariesclass-validatorZod, Valibot, ArkType
Type definitionDTO classInferred from schema
Reflection metadataUsed for class-based validationNot required
Existing NestJS projectsSuitable for decorator-based DTOsSuitable for schema-based validation
Reusable validation rulesDTO classes and decoratorsComposable schemas

You do not need to migrate all existing DTOs to Zod just because you are using NestJS 12.

Both pipes can be registered in the same application. This makes gradual adoption possible. For example, an existing application can continue using ValidationPipe for older modules while introducing StandardSchemaValidationPipe in new modules.

Avoid maintaining two different definitions of the same validation rules for a single endpoint. That creates unnecessary duplication and makes future changes harder to manage.

Returning Consistent Validation Errors

Validation errors should help API consumers understand what went wrong without exposing internal application details.

For example, an invalid registration request might produce multiple errors. Returning a clear error structure makes it easier for frontend developers to display field-level messages.

With Standard Schema validation, you can customize the exception response using exceptionFactory.

TypeScript
import {
  StandardSchemaValidationPipe,
  UnprocessableEntityException
} from '@nestjs/common';

app.useGlobalPipes(
  new StandardSchemaValidationPipe({
    exceptionFactory: (issues) =>
      new UnprocessableEntityException({
        message: 'Validation failed',
        errors: issues.map((issue) => ({
          path: issue.path
            ?.map((segment) =>
              typeof segment === 'object'
                ? segment.key
                : segment
            )
            .join('.'),
          message: issue.message
        }))
      })
  })
);

This example returns an HTTP 422 response when validation fails.

A response can look like this:

JSON
{
  "message": "Validation failed",
  "errors": [
    {
      "path": "email",
      "message": "Invalid email address"
    },
    {
      "path": "password",
      "message": "Password must contain at least 8 characters"
    }
  ]
}

The path identifies the field that failed validation, while message explains the problem.

This structure is useful for applications where frontend forms need to display validation messages next to individual input fields.

For consistency, use a similar error response structure across the application, including errors produced by business validation.

Validating Route Parameters and Query Parameters

Request validation is not limited to JSON request bodies. Route parameters and query parameters also come from external clients and should be checked before they are used.

Consider an endpoint that retrieves a user by ID:

TypeScript
import {
  Controller,
  Get,
  Param,
  ParseIntPipe
} from '@nestjs/common';

@Controller('users')
export class UsersController {

  @Get(':id')
  findOne(
    @Param('id', ParseIntPipe) id: number
  ) {
    return {
      message: 'User retrieved successfully',
      userId: id
    };
  }
}

The ParseIntPipe converts the route parameter into an integer. If someone sends /users/abc, NestJS rejects the request instead of passing an invalid value to the controller.

For schema-based validation, the same approach can be used with a Standard Schema:

TypeScript
import { z } from 'zod';

const userIdSchema = z.coerce
  .number()
  .int()
  .positive();

@Get(':id')
findOne(
  @Param('id', { schema: userIdSchema })
  id: number
) {
  return this.usersService.findOne(id);
}

The coerce method converts the incoming string into a number before applying the remaining validation rules.

This is useful when validating query filters, pagination values, and resource identifiers.

Advantages and Disadvantages of Request Validation

AdvantagesDisadvantages
Rejects invalid data before business logic executesAdds validation configuration
Reduces repetitive checks in controllersIncorrect rules can reject valid requests
Provides consistent API error responsesComplex validation may require custom logic
Improves type safetyRuntime validation does not replace TypeScript checks
Supports reusable DTOs and schemasMaintaining duplicate validation rules creates complexity
Helps protect application dataDoes not replace authorization or database constraints

Request validation should be treated as one layer of application protection. It cannot determine whether a user has permission to access a resource or whether a database operation is valid in the current business context.

Common Mistakes to Avoid

Creating DTOs Without Registering a Validation Pipe

Adding decorators to a DTO does not automatically activate validation. Register ValidationPipe globally or at the required controller or route level.

Forgetting Validation Decorators

When whitelist is enabled, properties without validation decorators are removed from the resulting DTO.

Make sure every accepted property has the appropriate validation rules.

Using TypeScript Types as Runtime Validation

A TypeScript interface disappears after compilation. It cannot validate incoming HTTP data.

Use a DTO class with validation decorators or a runtime schema such as Zod.

Returning Sensitive Information

Never include passwords, authentication tokens, or internal database details in validation responses.

Only return information that helps the API consumer correct the request.

Repeating Validation Rules in Multiple Places

Maintaining the same rules in a DTO, controller, and service creates unnecessary duplication.

Keep request validation in DTOs or schemas and reserve service-level checks for business rules.

Best Practices for Production Applications

Request validation should be consistent across all API endpoints. The following practices help maintain a reliable validation layer.

  1. Use global validation pipes when most endpoints share the same validation requirements.
  2. Keep request DTOs and schemas separate from controller implementations.
  3. Enable whitelist and forbidNonWhitelisted when unexpected request properties should be rejected.
  4. Use transformation carefully, especially when converting strings into numbers, dates, or other data types.
  5. Keep validation error responses consistent so frontend applications can handle them easily.
  6. Validate nested objects and arrays rather than checking only top-level properties.
  7. Avoid placing database queries inside basic DTO validation rules. Check duplicate records and business constraints in the service layer.
  8. Never assume that a validated request is an authorized request. Use guards and service-level authorization checks where required.
  9. Test both valid and invalid inputs, including missing fields, incorrect types, empty values, and unexpected properties.
  10. Keep validation dependencies updated and review their behavior when upgrading the application.

For important business operations, also enforce data integrity at the database level. For example, validating an email address does not prevent two concurrent requests from registering the same email. A unique database constraint is still necessary.

Troubleshooting Common Validation Problems

ProblemPossible causeSolution
DTO validation does not runValidationPipe is not registeredRegister the pipe globally or locally
Extra properties are acceptedWhitelist options are missingConfigure whitelist and forbidNonWhitelisted
Numeric parameters arrive as stringsHTTP parameters are strings by defaultUse ParseIntPipe or schema coercion
Zod schema is not appliedStandardSchemaValidationPipe is missingRegister the pipe and attach the schema to the parameter
TypeScript reports missing property errorsDTO and schema types are inconsistentUse the inferred schema type
Validation errors are difficult to displayDefault error response does not match frontend requirementsConfigure an exception factory
Valid nested data fails validationNested validation rules are incompleteDefine nested DTO or schema validation rules

When troubleshooting, test the endpoint with both valid and invalid payloads. Checking only the successful response will not confirm that the validation rules are working correctly.

Summary

Request validation is an important part of building reliable APIs with NestJS. It ensures that incoming data follows the expected structure before it reaches controllers and business logic.

NestJS 12 supports both the traditional ValidationPipe approach with class-validator and class-transformer and the newer StandardSchemaValidationPipe approach with libraries such as Zod, Valibot, and ArkType.

For existing applications, there is no need to replace working DTO-based validation. Teams can continue using their current implementation or introduce schema-based validation gradually.

The important part is to define clear validation rules, reject unexpected input, return useful error messages, and test the behavior of every important endpoint.

A well-configured validation layer makes APIs easier to maintain and helps prevent invalid data from reaching the parts of the application where it can cause bigger problems.