typescriptnestjstypescript-genericsclass-validator

NestJs Class validation for generic class


I am building a NestJS application with a pagination API that includes filtering and searching capabilities. To handle this, I created an abstract class, PaginationAbstract, which accepts two generic types for filters and search DTOs. However, I noticed that class-validator decorators are not validating the properties of these DTOs.

Here’s an example of my implementation:

Pagination Controller

  @Patch()
  public async orderPaginations(
    @Body() paginationOptions: PaginationAbstract<OrderPaginationFilterDto, OrderPaginationSearchDto>,
  ): Promise<PageDto<Orders>> {
    return await this.orderService.orderPagination(paginationOptions);
  }

Abstract Pagination Class

export class PaginationAbstract<FilterDto = void, SearchDto = void> {
  public page?: number;

  public limit?: number;

  public filter?: FilterDto;

  public orderBy?: SortDto[];

  public search?: SearchDto;

  public skip: number;

  public take: number;
}

Filter Dto Class

export class OrderPaginationFilterDto {
  public status: OrderStatusEnum[];

  @IsArray()
  @IsString({each:true})
  public productVariantIds: string[];

  @IsArray()
  @IsString({each:true})
  public operatorIds: string[];

  @IsArray()
  @IsString({each:true})
  public capsuleIds: string[];

  @IsArray()
  @IsString({each:true})
  public productIds: string[];

  
  public date: ByCreatedAt;
}

class ByCreatedAt {
  public gte?: Date;
  public lte?: Date;
}

The Problem

The validation decorators (e.g., @IsArray, @IsString) in OrderPaginationFilterDto are not working when passed through the PaginationAbstract class.

I tried initializing the properties in the PaginationAbstract constructor, but the constructor does not seem to be invoked at all.

My Question

Why aren’t the class-validator decorators validating the properties in the generic PaginationAbstract class?

Is there a way to make class-validator work with generic abstract classes like this?


Solution

  • TypeScript generics are type-erased during runtime, they are a static compile time only check that helps you write type safe code, but they can not influence your running code.

    Because of the TypeScript feature emitDecoratorMetadata, the TypeScript compiler leaves behind a little type information breadcrumb when you use:

    @Body() paginationOptions: PaginationAbstract<OrderPaginationFilterDto, OrderPaginationSearchDto>
    

    The breadcrumb left behind contains only the runtime type of PaginationAbstract<OrderPaginationFilterDto, OrderPaginationSearchDto> which after type erasure is simply the class PaginationAbstract.

    Putting this information together, the type object that NestJS can detect from the emitted TypeScript decorator metadata, which it passes to class-validator is PaginationAbstract, but the 2 generics disappeared.

    If you want to influence which runtime validation class is passed to class-validator you can't use generics, but there are 2 other patterns that can work for you:

    I will explain both approaches, using mock-ups, and illustrative custom validation constraints (e.g. IsValidPage would be a custom decorator to validate page info).

    Inheritance

    You make PaginationAbstract the child class of everything you need and create child classes that extend it.

    class Pagination {
      @IsValidPage()
      page: Page
    }
    
    class FilteredPagination extends Pagination {
      @IsValidFilters()
      filters: Filters
    }
    
    class SearchFilteredPagination extends FilteredPagination {
      @IsValidSearch()
      search: Search
    }
    
    class SearchFilterPaginatedOrderDTO extends SearchFilteredPagination {
      @IsArray()
      productVariantIds: string[]
    }
    

    This allows you to create a tiered system where each child is incrementally more than its parent.

    What may be lacking for your use case though is that the page, search, and filter parts are not specific to your order. To put it symbolically:

    DTO = [ Order [ Search [ Filter [ Pagination ]]]]
    

    while what you would like is for the order to be a parameter of each part:

    DTO = [ Order [ Pagination(Search(Order), Filter(Order)) ]]
    

    A pattern like this is only possible through composition of classes produced specifically for each resource. Enter, composition, and mixins

    Mixins

    Since in JavaScript each class can only inherit from 1 parent class, you'll have to follow the mixin pattern:

    // These imports conflict in name, so rename one of them
    import { Type as ValidateType } from 'class-transformer';
    import { Type } from '@nestjs/common';
    
    interface PaginatedResource<S, F> {
      page?: number;
      limit?: number;
      skip: number;
      take: number;
      orderBy?: SortDto[];
      search?: S;
      filter?: F;
    }
    
    const Pagination = <S extends Type, F extends Type>(
      searchDTO: S,
      filterDTO: F,
      parent?: Type
    ): Type<PaginatedResource<S, F>> => {
      class Paginated extends (parent ?? class {}) {
        @IsOptional()
        @IsNumber()
        public page?: number;
        @IsOptional()
        @IsNumber()
        public limit?: number;
        @IsNumber()
        public skip!: number;
        @IsNumber()
        public take!: number;
    
        @IsOptional()
        @ValidateNested()
        @ValidateType(() => filterDTO)
        public filter?: F;
    
        @IsOptional()
        @ValidateNested()
        public orderBy?: SortDto[];
    
        @IsOptional()
        @ValidateNested()
        @ValidateType(() => searchDTO)
        public search?: S;
      }
      return Paginated;
    };
    

    And you use it as:

    const OrderPaginationDTO = Pagination(OrderPaginationSearchDto, OrderPaginationFilterDto)
    
    // ...
    
      @Patch()
      public async orderPaginations(
        @Body() paginationOptions: OrderPaginationDTO,
      ): Promise<PageDto<Orders>> {
        return await this.orderService.orderPagination(paginationOptions);
      }
    

    Now, rather than using type erased generics, at runtime, the Pagination function produces a custom made class with ValidateNested validation decorators attached to it that, and a Type marker (renamed to ValidateType) that attach your 2 custom DTOs to the class so that ValidateNested can recurse into them!

    On top of that we rebirthed your PaginationAbstract<F, S> as the PaginatedResource<F, S> interface, so that TypeScript knows that the dynamically created class that Pagination returns follows a predictable interface generic over your 2 DTOs. This marries the dynamic class factory with the static TypeScript compiler, and makes Pagination fully type safe.

    Final aside on class-validator

    This library works by calling: validate(object) and then taking the class and scraping it for metadata. When you say:

    class X {
     @ValidateNested()
     y: Y
    }
    

    it knows to take object.y and call validate(object.y) on it. However, the type hint : Y does not exist anymore, so object.y needs to be an instance of class Y at runtime, which is where the Type(() => Y) (ValidateType) decorator of class-transformer helps us out, NestJS's validation pipe calls class-transformer first, unless you configure it not to :)