A pipe in Nestjs to validate DTOs conditionally.
Tags: nestjs, backend, pipes, typescript
Published: 2024-03-08 12:39:23

A week ago, I was working on an API, and it was a dynamic one or in other words a one for four. The endpoint: http://localhost:5000/api/v2/item/{itemId}/readings. Depending on the item ID, the item changes and query params passed also change. And in order to validate these changing query params I required a dynamically validating pipe.

The Internet didn't help (maybe I didn't search with the right keywords). Tried ChatGPT, Gemini, and Bing, which had Copilot. TBH, Gemini was the dumbest, Bing the smartest but with a hiccup, and surprisingly Chatgpt provided the nearest solution. After tweaking some lines of code, I got what I wanted and thought to share it here. Please don't hesitate to point out the mistakes in the comments.

Starting with the Pipe I created

```typescript
@Injectable()
export class CustomDTOValidationPipe implements PipeTransform<any> {
  transform(value: any, { metatype }: ArgumentMetadata): Promise<any> {
    if (!metatype || !this.isDtoType(metatype)) {
      return value;
    }

    const dtoClass = moduleDTORegistry[value.moduleType];

    if (!dtoClass) {
      throw new BadRequestException(AppConstants.INVALID_MODULE_TYPE);
    }

//to make the query object an instance of the DTO passed.
    const dtoInstance = plainToClass(DynamicModulesParamsDto, value);
    const errors = validateSync(dtoInstance, {
      skipMissingProperties: false,
      validationError: { target: true },
    });
  private isDto(metatype: any): boolean {
    return metatype && metatype.prototype instanceof Object;
  }
  if (errors.length > 0) {
      throw new BadRequestException(this.extractErrorMessages(errors));
    }

    return value;
  }

  private isDtoType(metatype: any): boolean {
    return metatype && metatype.prototype instanceof Object;
  }

  /* {
     "target": {
       "dateProcessed": "12-03-202",
       "timezone": "12:34:34323",
       "timestamp": "213",
       "state": "213",
       "plate": "123",
       "type": "213",
       "minConfidence": "213",
       "maxConfidence": "213",
       "moduleType": "hazmat"
     },
     "value": "12-03-202",
     "property": "dayProcessed",
     "children": [],
     "constraints": { "isDate": "dateProcessed must be in the dd:mm:yyyy format" }
   },
 given above is the structure of error we get from 
 validateSync method. Therefore, we need to take only the constraints to return it in the response.*/

   private extractErrorMessages(errors: any[]): string[] {
    return errors.map((error) => {
      const errorConstraints = error.constraints;
      return errorConstraints[Object.keys(errorConstraints)[0]];
    });
  }
}
```


For the sake of reusability, I have created a mapping for the 'moduleType' and its respective DTO.

```typescript
export const moduleDTOMapping = {
  person: PersonQueryParamsDto,
}
```
Since this pipe is in place, whenever a new kind of item comes in this endpoint you just have to add its name as the property and DTO in the above mapping.

When a client  hits the endpoint
pipe checks the 'moduleType' and validates the query params with its corresponding DTO.

**dynamic-items.controller.ts**

```typescript
  @ApiQuery({
    name: 'moduleType',
    type: 'string',
    enum: ModuleType,
    required: true,
  })
  @Get(':itemId/readings')
  getReadings(
    @Req() req: Request,
    @Param('item', new IdValidationPipe()) id: number,
    @Query(new DynamicDTOValidationPipe()) queryParams: DynamicModulesParamsDto,
  ) {
      //controller logic goes here
    }
```

To recapitulate, This article creates a pipe for validating multiple DTOs based on a query param. For example, for type = person, a person's DTO will be validated, and if the type is a dog, then the dog's DTO is validated. Given that the DTO and type mapping are declared somewhere.