DEV Community

Yaniv Trablesi
Yaniv Trablesi

Posted on • Edited on

Why you should not use class-validator in NestJS!

Introduction

I encountered NestJS for the first time about five years ago.

I was making a transition from .net to node js, so I was looking for a strongly-typed, well-documented server-side library.

I found NestJS.

The class validator decorators have always bothered me, and I even wrote a lint that forced programmers to add the validation.

We recently decided at my work to move to NestJS, so this issue has popped up again and I found a great solution!

Since we can use third-party libraries to do validation using OpenAPI documents, all we need to do is to “compile” Nest controllers to OpenAPI documents. Here we go!

Let's start

There are two critical things to consider when writing APIs:

  • Documentation - Your API must be understood by your consumers. There is a standard interface for that - https://swagger.io/specification/

  • Validation - You don’t want to query the database or do something with some wrong input data.

First, we'll do it in the old primitive way, and then I'll show you my way.

We will create a simple route that queries events by time, type, and location.

The Old Ugly Way

Create a new nest application -
nest new the-old-way

Create Events controlller -
nest g resource Events

(Do not generate CRUD entry points that will add a lot of code that we do not need.)

Let's add the events route:



import { Controller, Get, Param, Query } from "@nestjs/common";

interface Event {
  timestamp: number;
  type: EventType;
  locationId: number;
  id: number;
}

enum EventType {
  SYSTEM = "system",
  USER = "user",
}

@Controller("events")
export class EventsController {
  @Get(":locationId")
  getEvents(@Param("locationId") locationId: number, 
            @Query("ids") ids?: number[], 
            @Query("type") type?: EventType): Promise<Event[]> {
    return new Promise((res) => res([]));
  }
}


Enter fullscreen mode Exit fullscreen mode

Location path parm is a number and it is required.

ids query param is an array of numbers (optional).

type query param can be only "system" or "user"(optional).

The response is an array of events.

Let's add a validation pipe (official doc)
npm i --save class-validator class-transformer
In the main.ts file, add app.useGlobalPipes(new ValidationPipe());

Let's add an openAPI generator by following the official article.

When you open http://localhost:3000/api/ you can see the schema:

Image description

There are some problems here -

  1. Query parameters are mandatory,but they should be optional.

  2. The type parameter is a string, but it should be an enum.

  3. There is no response schema.

  4. There is no validation - you can test this by sending a string in the locationId param - http://localhost:3000/events/some-string

When we compile to javascript, all the types disappear. NestJS solves this problem by using decorators.
So, let's try to fix this with ApiProperty and class validator decorators.

Let's try to fix that with ApiProperty and class validator decorators.

To use decorators, we first need to move our parameters to some class:



class GetEventsPathParams {
 @IsNumber() locationId: number;
}

class GetEventsQueryParams {
 @IsArray() @IsOptional() ids?: number[];
 @IsEnum(EventType) @IsOptional() type?: EventType;
}

@Controller("events")
export class EventsController {
 @Get(":locationId")
 getEvents(@Param() params: GetEventsPathParams, @Query() query: GetEventsQueryParams): Promise<Event[]> {
   return new Promise((res) => res([]));
 }
}



Enter fullscreen mode Exit fullscreen mode

Success! Now we get a validation error -
{"statusCode":400,"message":["locationId must be a number conforming to the specified constraints"],"error":"Bad Request"}

Let's see what happened with the opanAPI doc -

Image description
Whoops! All our parameters were gone!

Let's try to fix this by adding ApiResponse decorators -



class Event {
 @ApiProperty() timestamp: number;
 @ApiProperty() type: EventType;
 @ApiProperty() locationId: number;
 @ApiProperty() id: number;
}

enum EventType {
 SYSTEM = "system",
 USER = "user",
}

class GetEventsPathParams {
 @ApiProperty({ required: true }) @IsNumber() locationId: number;
}

class GetEventsQueryParams {
 @ApiProperty({ required: false, type: ["number"] }) @IsArray() @IsOptional() ids?: number[];
 @ApiProperty({ required: false, enum: EventType }) @IsEnum(EventType) @IsOptional() type?: EventType;
}

@Controller("events")
export class EventsController {
 @Get(":locationId")
 @ApiResponse({ type: [Event] })
 getEvents(@Param() params: GetEventsPathParams, @Query() query: GetEventsQueryParams): Promise<Event[]> {
   return new Promise((res) => res([]));
 }
}


Enter fullscreen mode Exit fullscreen mode

And voila - it looks much better:

Image description

The point is clear:

  • You need to declare each type 3 times!
  • You need to learn class validator and @nestjs/swagger!
  • You have to create classes that will never be instantiated!
  • To allow the declaration of a class property without initialization, Nest sets the strict-flag to false. In other words, it is less type-safe.

The solution for all this is to create a package that can “compile” NestJS controllers to OpenAPI documents.

Feel free to look at my code on Github, any contributions or suggestions are welcome!

Let's start again from the beginning-

Create a new nest application -
nest new the-new-way

Create Events controlller -
nest g resource Events

Do not generate CRUD entry points that will add a lot of code that we do not need.

Let's add the events route:



interface Event {
  timestamp: number;
  type: EventType;
  locationId: number;
  id: number;
}

enum EventType {
  SYSTEM = "system",
  USER = "user",
}

@Controller("events")
export class EventsController {
  @Get(":locationId")
  getEvents(@Param("locationId") locationId: number,
            @Query("ids") ids?: number[],
            @Query("type") type?: EventType): Promise<Event[]> {
    return new Promise(resolve => resolve([]));
  }
}


Enter fullscreen mode Exit fullscreen mode

Generate openapidoc with nest-openapi-gen package -
Run npm i -D nest-openapi-gen
Add openapi.generator.ts file in the root -



import { generate } from "nest-openapi-gen";
generate({ filePath: "./openapi.json" });


Enter fullscreen mode Exit fullscreen mode

Change the build script to generate the openAPI document -
"build": "ts-node openapi.generator.ts && nest build",

Run npm run build

You can see the file generated in the folder.

The last step is to add some openAPI UI to show our document.
we can use swagger-ui-express for that
Run npm i @nestjs/swagger swagger-ui-express
Add this code in main.ts file



const document = JSON.parse(readFileSync("./openapi.json", { encoding: "utf-8" }));
SwaggerModule.setup("api", app, document);


Enter fullscreen mode Exit fullscreen mode

Open http://localhost:3000/api and see the magic.

Image description

But wait, we aren’t finished yet, we still need to take care of the validation.

Since we have the API documentation, we can use it for validation.

Let's use express-openapi-validator for that:

Run npm i express-openapi-validator

Add middleware:



app.use(OpenApiValidator.middleware({
 apiSpec: require('../openapi.schema.json'),
 validateResponses: true
}));


Enter fullscreen mode Exit fullscreen mode

Add a global exception filter -



@Catch()
export class GlobalExceptionFilter implements ExceptionFilter {
 catch(exception: Error & { context?: any }, host: ArgumentsHost) {
   const ctx = host.switchToHttp();
   const response = ctx.getResponse<Response>();
   const request = ctx.getRequest<Request>();
   const status =
     exception instanceof HttpException
       ? exception.getStatus()
       : exception instanceof BadRequest
       ? exception.status
       : HttpStatus.INTERNAL_SERVER_ERROR;

   response.status(status).json({
     statusCode: status,
     timestamp: new Date().toISOString(),
     path: request.url,
     message: exception.message,
     errors: (exception as BadRequest).errors,
   });
 }
}


Enter fullscreen mode Exit fullscreen mode

And that's it! Now we have validation and openapi documents for our code, without adding unnecessary classes and decorators.

One last thing, we need to set the strict-flag to true to avoid using properties without initializing.

Just add "strict": true, and remove "strictNullChecks": false,from your tsconfg file.

You can find the source code here

Top comments (3)

Collapse
 
marklai1998 profile image
Mark Lai

"should not use" is a hot take

You can use the @nestjs/swagger cli plugin to infer types from ts, don't need the Api decorator
docs.nestjs.com/openapi/cli-plugin...

It would solve the "type 3 times" and "no response type" issue

as for the "You have to create classes that will never be instantiated", it's too opinioned that based on developer taste, from a Java spring background, I think it's intuitive and easy to follow, on the other hand, I understand it crosses the boundary that makes types have effects in run time

I know someone would hate class, but why are you using Nest if you hate it? Have you ever think how the Nest DI works, with just a typescript and it knows what to inject? It uses emitDecoratorMetadata to achieve it, it also makes typescript type part of your runtime code already.

Collapse
 
mamged profile image
mAmged • Edited

I guess you did read the documentation very well.
The solution is already there this could just solve your problem:

@IsEnum(EventType)
@ApiProperty({
     description: 'property property',
     enum: EventType
   })
Enter fullscreen mode Exit fullscreen mode
Collapse
 
yantrab profile image
Yaniv Trablesi

Thanks @mamged , i fixed it. but the point is clear also without it.