Menu
CoddyTech

NestJS Validation, Pipes and DTO Interview Questions

DTOs, ValidationPipe options, built-in and custom pipes, Zod schemas and response serialization.

11 questions
  1. What is a DTO in NestJS, and why is it a class and not an interface?

    A DTO (data transfer object) describes the shape of data crossing the API boundary: what a request body must contain or what a response returns.

    export class CreateOrderDto {
      @IsString() productId: string;
      @IsInt() @Min(1) quantity: number;
    }
    

    It is a class because classes exist at runtime. ValidationPipe uses the parameter's class (from decorator metadata) to know which rules to apply, and class-validator's decorators attach those rules to the class. An interface is erased by the compiler, so @Body() dto: CreateOrderInterface would receive the body unchecked. Keep DTOs separate from database entities, so a new column never becomes a writable API field by accident.

  2. How do you validate a request body in NestJS?

    Decorate a DTO class with class-validator rules and enable ValidationPipe. Invalid input never reaches the handler: the pipe answers 400 with every failed rule.

    NestJS
    import { Body, Controller, Get, Module, Post, ValidationPipe } from '@nestjs/common';
    import { NestFactory } from '@nestjs/core';
    import { IsEmail, IsInt, IsString, Min, MinLength } from 'class-validator';
    
    class CreateUserDto {
      @IsString() @MinLength(2) name: string;
      @IsEmail() email: string;
      @IsInt() @Min(13) age: number;
    }
    
    // GET /users
    @Controller('users')
    class UsersController {
      private users: CreateUserDto[] = [];
      @Get() all() { return this.users; }
      @Post() create(@Body() dto: CreateUserDto) {
        this.users.push(dto);
        return { saved: dto, isDto: dto instanceof CreateUserDto };
      }
    }
    
    @Module({ controllers: [UsersController] })
    class AppModule {}
    
    async function bootstrap() {
      const app = await NestFactory.create(AppModule);
      app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }));
      await app.listen(3000);
    }
    bootstrap();

    Send POST /users with {"name": "A", "email": "nope", "age": "20"}: the 400 lists four messages, and age fails IsInt because "20" is a string. With {"name": "Ada", "email": "ada@example.com", "age": 36} you get 201 and isDto: true, since transform turned the plain body into a CreateUserDto instance.

  3. What do whitelist and forbidNonWhitelisted do in ValidationPipe?

    whitelist: true strips every property that has no validation decorator in the DTO. forbidNonWhitelisted: true rejects the request with 400 instead of silently stripping.

    They prevent mass assignment: a client sending {"name": "Ada", "isAdmin": true} to an endpoint that saves the body must not become an admin because the entity happens to have an isAdmin column. In the ValidationPipe example on this page, adding "isAdmin": true to the body returns 400 with property isAdmin should not exist.

    A property with no rule of its own needs @Allow() or @IsOptional() to survive the whitelist.

  4. What does transform: true do, and why does "20" still fail @IsInt()?

    transform: true makes the pipe return an instance of the DTO class instead of the plain object, and converts route params and query values to the declared primitive type: @Param('id') id: number arrives as a number.

    It does not convert values inside a JSON body. A body field typed number that the client sends as "20" is still a string, so @IsInt() fails. Two ways to accept it:

    // per field
    @Type(() => Number) @IsInt() age: number;
    
    // everywhere (looser)
    new ValidationPipe({ transform: true, transformOptions: { enableImplicitConversion: true } });
    

    Implicit conversion is convenient for query DTOs but surprising for booleans: the string "false" converts to true.

  5. What are pipes in NestJS? Name the built-in ones.

    A pipe transforms or validates a handler argument before the handler runs. If it throws, the handler is never called. Built-in pipes: ValidationPipe, ParseIntPipe, ParseFloatPipe, ParseBoolPipe, ParseArrayPipe, ParseUUIDPipe, ParseEnumPipe, ParseDatePipe, DefaultValuePipe, ParseFilePipe, and in NestJS 12 StandardSchemaValidationPipe.

    NestJS
    import { Controller, Get, Module, Param, ParseIntPipe, Query, DefaultValuePipe } from '@nestjs/common';
    import { NestFactory } from '@nestjs/core';
    
    // GET /products/abc
    @Controller('products')
    class ProductsController {
      @Get()
      list(@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number) {
        return { page, type: typeof page };
      }
    
      @Get(':id')
      one(@Param('id', ParseIntPipe) id: number) {
        return { id, type: typeof id };
      }
    }
    
    @Module({ controllers: [ProductsController] })
    class AppModule {}
    
    async function bootstrap() { const app = await NestFactory.create(AppModule); await app.listen(3000); }
    bootstrap();

    /products/abc answers 400 with Validation failed (numeric string is expected). Send GET /products/42: the id arrives as a number. GET /products uses DefaultValuePipe(1), and ?page=x fails like the first request.

  6. How do you write a custom pipe?

    Implement PipeTransform: return the (possibly transformed) value, or throw to reject the request.

    @Injectable()
    export class ParseSlugPipe implements PipeTransform<string, string> {
      transform(value: string, metadata: ArgumentMetadata) {
        const slug = value.trim().toLowerCase();
        if (!/^[a-z0-9-]{3,60}$/.test(slug)) {
          throw new BadRequestException(`Invalid ${metadata.data}: ${value}`);
        }
        return slug;
      }
    }
    
    @Get(':slug')
    find(@Param('slug', ParseSlugPipe) slug: string) {}
    

    metadata tells you where the value came from (type is body, query, param or custom; data is the key). Pass the class, not an instance, when the pipe needs injected services.

  7. How do you validate nested objects and arrays?

    Mark the property with @ValidateNested() and tell class-transformer which class to build with @Type(). Without @Type, the nested value stays a plain object and its rules never run.

    class AddressDto {
      @IsString() city: string;
      @IsPostalCode('any') zip: string;
    }
    
    class CreateCustomerDto {
      @ValidateNested() @Type(() => AddressDto)
      address: AddressDto;
    
      @IsArray() @ArrayMaxSize(5)
      @ValidateNested({ each: true }) @Type(() => AddressDto)
      otherAddresses: AddressDto[];
    
      @IsString({ each: true }) tags: string[];
    }
    

    { each: true } applies a rule to every element of an array, set or map.

  8. How do you create an update DTO without repeating the create DTO?

    Use the mapped type helpers: PartialType makes every property optional and keeps the validation rules, PickType and OmitType select properties, and IntersectionType merges two DTOs.

    import { OmitType, PartialType, PickType } from '@nestjs/mapped-types';
    
    export class UpdateUserDto extends PartialType(CreateUserDto) {}
    export class LoginDto extends PickType(CreateUserDto, ['email', 'password']) {}
    export class PublicUserDto extends OmitType(CreateUserDto, ['password']) {}
    

    Import them from @nestjs/swagger instead when you generate OpenAPI docs, so the schema follows too. Unlike TypeScript's Partial<T>, these produce real classes that carry the validation metadata.

  9. Can you validate with Zod instead of class-validator in NestJS?

    Yes. In NestJS 12, @Body(), @Query() and @Param() take a { schema } option for any Standard Schema library (Zod, Valibot, ArkType), and StandardSchemaValidationPipe validates it. Before 12, the usual way was a custom pipe calling schema.parse().

    NestJS
    import { Body, Controller, Get, Module, Post, Query, StandardSchemaValidationPipe } from '@nestjs/common';
    import { NestFactory } from '@nestjs/core';
    import { z } from 'zod';
    
    const SearchQuery = z.object({
      q: z.string().min(2),
      limit: z.coerce.number().int().max(50).default(10),
    });
    
    // GET /search?q=nest&limit=5
    @Controller('search')
    class SearchController {
      @Get()
      find(@Query({ schema: SearchQuery }) query: z.infer<typeof SearchQuery>) {
        return { query, limitType: typeof query.limit };
      }
    }
    
    @Module({ controllers: [SearchController] })
    class AppModule {}
    
    async function bootstrap() {
      const app = await NestFactory.create(AppModule);
      app.useGlobalPipes(new StandardSchemaValidationPipe());
      await app.listen(3000);
    }
    bootstrap();

    The first request returns limit as a number: z.coerce converted the query string. Send GET /search?q=n&limit=500 to get a 400 that lists both issues with their paths. Drop limit from the URL and the schema's default of 10 applies.

  10. How do you hide a field such as a password hash from API responses?

    Return a class instance whose sensitive fields carry @Exclude() and enable ClassSerializerInterceptor, which runs class-transformer's instanceToPlain on the result.

    NestJS
    import { ClassSerializerInterceptor, Controller, Get, Module, UseInterceptors } from '@nestjs/common';
    import { NestFactory } from '@nestjs/core';
    import { Exclude, Expose } from 'class-transformer';
    
    class UserEntity {
      id: number;
      email: string;
      @Exclude() passwordHash: string;
      constructor(data: Partial<UserEntity>) { Object.assign(this, data); }
      @Expose() get handle() { return this.email.split('@')[0]; }
    }
    
    // GET /me
    @Controller()
    @UseInterceptors(ClassSerializerInterceptor)
    class MeController {
      @Get('me')
      me() { return new UserEntity({ id: 1, email: 'ada@example.com', passwordHash: 'x9$k' }); }
    
      @Get('raw')
      raw() { return { id: 1, email: 'ada@example.com', passwordHash: 'x9$k' }; }
    }
    
    @Module({ controllers: [MeController] })
    class AppModule {}
    
    async function bootstrap() { const app = await NestFactory.create(AppModule); await app.listen(3000); }
    bootstrap();

    GET /me answers without passwordHash and with the computed handle. Send GET /raw: a plain object literal passes through untouched and leaks the hash, the trap interviewers look for. Safer still is mapping entities to response DTOs explicitly, so a new column is never exposed by default.

  11. class-validator or Zod for validation in a NestJS project?

    Both work in NestJS 12. The trade-off:

    class-validatorZod (Standard Schema)
    StyleDecorators on DTO classesSchema objects, types inferred
    Swagger@nestjs/swagger reads DTO classesSupported in NestJS 12 through the schema
    Runtime typeDTO class instancePlain parsed object
    Shared with frontendNo (decorators, classes)Yes, the same schema
    Transformclass-transformer, @Type()z.coerce, .transform()

    Pick one per codebase. class-validator matches most existing Nest code and docs; Zod fits a monorepo that shares schemas with a TypeScript frontend and avoids keeping a type and a validator in sync.

Coddy programming languages illustration

Learn to code with Coddy

Get started