DTO Orchestration

A robust DTO structure to aid us with this endeavor.

We'll start with the DTOs for filtering. A product may be filtered by name, price or category. Well, filtering by name is something very common and that may be done with different entities. Due to this, let's create a dedicated DTO for it in querying -> dto -> name-filter.dto. With it, we may then create the DTO for the filter fields of an entity without the need to define the name field everytime.

export class NameFilterDto {
  @IsOptional()
  @IsString()
  readonly name?: string;
}

And that's what will be done now. Let's then create the file products -> dto -> querying -> products-filter.dto. Here, we should create the two remaining fields and extend the NameFilterDto.

export class ProductsFilterDto extends NameFilterDto {
  @IsOptional()
  @IsCurrency()
  readonly price?: number;

  @IsOptional()
  @IsCardinal()
  readonly categoryId?: number;
}

Excellent! We have the fields to filter by and their respective validations. Let's then proceed to create the DTOs for sorting.

With sorting, there will be a difference. When filtering by name, for example, we may pass any text we want, as we are stating what should be in the name of the product. However, when sorting, we should use predefined values, as we'll sort by the existing fields.

Well, sorting involves two steps: choosing the sort field by which the sorting will happen, and the order method. There are only two order methods: ascending and descending. Let's then represent them in the file querying -> dto -> order.dto.

export class OrderDto {
  @IsOptional()
  @IsString()
  readonly order?: string;
}

As was said, here predefined values are expected. There is no sense in accepting any text in the order field, as it should only have the values ASC or DESC. Due to this, this validation is not enough. Fortunately, there's an interesting way to solve this.

First, we can create a constant which is an array with the allowed values.

const Order = ['ASC', 'DESC'] as const;

After that, we can create a namesake type which is the union of these values.

type Order = (typeof Order)[number];

This article better explains this syntax, if there is interest for a further read.

After that, we just need to adjust the DTO. Here, ASC is used by default if no order is sent.

export class OrderDto {
  @IsOptional()
  @IsIn(Order)
  readonly order?: Order = 'ASC';
}

TypeScript can infer when the constant or the type is being used.

Now, the DTO to represent both the sort and order fields will be created in products -> dto -> querying -> products-sort.dto. If no sort field is sent, products will be sorted by name. Every sorting process uses the fields sort and order, meaning, respectively, the field to sort by and how this field will be ordered. Therefore, each entity will have its own sort field with predefined values.

const Sort = ['name', 'price'] as const;
type Sort = (typeof Sort)[number];

export class ProductsSortDto extends OrderDto {
  @IsOptional()
  @IsIn(Sort)
  readonly sort?: Sort = 'name';
}

We now have a DTO for filtering and another one for sorting. As only a single DTO may be used, what should be done now is to create a new DTO that will be the combination of these two, together with the one for pagination. We can do it in the same folder, and name the file products-query.dto.

export class ProductsQueryDto extends IntersectionType(
  PaginationDto,
  ProductsFilterDto,
  ProductsSortDto,
) {}

With all these DTOs in hand, we may then proceed to actually apply filtering and sorting.

Commit - Orchestrating dtos for filtering and sorting

Last updated