Всем привет! Наткнулся недавно на статью Переосмысление DTO в Java и решил интерпретировать предложенный подход к TypeScript и NestJS. Данный стек используется нашей командой повсеместно, отсюда и мой выбор.

Вспомним, что такое DTO в рамках фреймворка NestJS. Этот инструмент помогает описывать структуры данных передаваемых между клиентом и сервером, а также слоями приложения. Для построения такого рода объектов чаще всего используется класс с описанными полями и валидацией. Вроде ничего сложного.

Проблемы начинаются в тот момент, когда для некой сущности в приложении появляется с десяток DTO'шек, что приводит нас к дублированию кода. Одни и те же поля вроде name, email, id повторяются из DTO в DTO, а внесение изменений превращается в рутинную правку десятков файлов.

В этой статье я попробую показать, как можно организовать DTO более модульно. Такой подход позволяет писать более читаемый, масштабируемый код с соблюдением DRY принципа. Начнем!

Базовая структура

Для начала рассмотрим привычный способ создания DTO. Возьмем для примера создание юзера:

export class CreateUserDto {
  name: string;
  email: string;
  password: string;
}

После добавления валидации наш класс будет выглядеть следующим образом:

import { IsString, IsEmail, Length } from 'class-validator';

export class CreateUserDto {
  @IsString()
  @Length(2, 50)
  name: string;

  @IsEmail()
  email: string;

  @IsString()
  @Length(6, 100)
  password: string;
}

Это читаемо и удобно, но до тех пор, пока не появляется UpdateUserDto, UserResponseDto, и другие варианты. Понятно, что в итоге мы получим дубликаты полей с одними и теми же правилами. И когда бизнес попросит нас что-то поменять, нам придется идти по всем этим файлам.

Возникает закономерный вопрос: что мы можем с этим сделать?

Путь к модульности через миксины

Обычные интерфейсы и implements в TypeScript не подходят для аннотаций валидации, таких как @IsEmail или @Length, потому что интерфейсы не существуют в рантайме. А class-validator использует именно рантайм-декораторы.

Дабы сохранить модульность и обеспечить рабочую валидацию, можно использовать функции-миксины. Это функции, которые принимают базовый класс и возвращают новый — с добавленным полем и декораторами.

Возьмем структуру данных с полями name, email и password. Вместо того чтобы повторять эти поля в каждом DTO, создадим для каждого отдельный класс:

// dto/fields/with-name.dto.ts
import { IsString, Length } from 'class-validator';

export function WithName<T extends new (...args: any[]) => any>(Base: T) {
    abstract class WithName extends Base {
        @IsString()
        @Length(2, 50)
        name: string;
    }
    return WithName;
}

// dto/fields/with-email.dto.ts
import { IsEmail } from 'class-validator';

export function WithEmail<T extends new (...args: any[]) => any>(Base: T) {
    abstract class WithEmail extends Base {
        @IsEmail()
        email: string;
    }
    return WithEmail;
}

// dto/fields/with-password.dto.ts
import { IsString, Length } from 'class-validator';

export function WithPassword<T extends new (...args: any[]) => any>(Base: T) {
    abstract class WithPassword extends Base {
        @IsString()
        @Length(6, 100)
        password: string;
    }
    return WithPassword;
}

Теперь чтобы описать тот же DTO для создания пользователя достаточно собрать его из строительных блоков:

// dto/create-user.dto.ts
import { WithName } from './fields/with-name.dto';
import { WithEmail } from './fields/with-email.dto';
import { WithPassword } from './fields/with-password.dto';

export class CreateUserDto extends WithName(WithEmail(WithPassword(class {}))) {}

Все поля будут включены в финальный класс с валидацией. NestJS и class-validator увидят все декораторы, как будто они определены напрямую.

На этом простом примере видно, что такой подход дает явную структуру и единый источник правды для каждого поля. Но для новичков, не знакомых с миксинами, такая структура может быть неочевидной.

Заключение

Использование модульных DTO в TypeScript — это хороший способ улучшить структуру и поддерживаемость кода, особенно в больших проектах, где DTO могут повторяться во многих местах. Он позволит снизить вероятность ошибок при изменении, а также упростит процесс сопровождения.

А как вы организуете DTO в своих проектах? Используете ли подобный подход или предпочитаете что-то другое? Какие еще минусы видите в таком подходе?

P.S.: Это моя первая статья на Хабре, буду рад любой конструктивной критике :-)

Комментарии (16)


  1. TsarS
    13.05.2025 18:41

    Почему то всегда думал, что DTO просто для переноса данных, а валидация должна быть в хендлерах


    1. MyraJKee
      13.05.2025 18:41

      Абсолютно так же мысль возникла и вопрос - неужели так принято в nestjs?


  1. ggdev
    13.05.2025 18:41

    extends WithName(WithEmail(WithPassword(class {})))

    А почему бы не использовать IntersectionType()?

    https://docs.nestjs.com/techniques/validation#mapped-types


    1. shagor Автор
      13.05.2025 18:41

      Да, можно использовать в том числе и его


  1. anikitinhabr
    13.05.2025 18:41

    https://docs.nestjs.com/openapi/mapped-types

    /IntersectionType

    Он в том числе с метаданными сваггер-декораторов полей нормально отрабатывает, перетаскивает их в результирующий класс.


    1. shagor Автор
      13.05.2025 18:41

      Как я уже ответил, да, можно использовать и IntersectionType. Я больше в целом о подходе собирать DTO из набора полей, на сколько это удобно или нет, в тех или иных случаях, и какие для этого есть средства в том же NestJS. IntersectionType сам не использовал.


      1. mat-twg
        13.05.2025 18:41

        Ваш велосипед очень громоздкий, откройте документацию там есть примеры, DTO - суть в его названии.
        Понятно что дублирование полей не айс, но всё уже придуманно до нас:

        export class DeleteLicDto extends OmitType(LicDto, ['code', 'expireDate']) {
          @Expose()
          @ApiProperty({ default: 1, required: false })
          @IsNumber()
          @IsOptional()
          @NotEquals(0)
          @Type(() => Number)
          code?: number;
        }

        Можно и с дженериками, конечно, нечто подобное хотел увидеть в статье..

        export abstract class ConstructableDto<
          T = ConstructableDto<Record<string, unknown>>,
        > {
          constructor(dto?: Partial<T>) {
            if (dto) {
              Object.assign(this, dto);
            }
          }
        }
        
        export class SuccessDto extends ConstructableDto<SuccessDto> {
          @Expose()
          @ApiProperty({
            type: Boolean,
            description: 'Is success execute request',
            example: true,
          })
          success: boolean;
        }
        
        export class ListResponseDto<
          T = Record<string, unknown>,
        > extends ConstructableDto<ListResponseDto<T>> {
          @ApiProperty({ description: 'Selected data' })
          data!: T[];
        
          @ApiProperty({ description: 'Total numbers of entries' })
          count!: number;
        }

        Но в вашем случае это:
        https://docs.nestjs.com/techniques/validation


  1. YegorP
    13.05.2025 18:41

    Ну не знаааю. Это какой-то нечитаемый DRY obsession.

    export class CreateUserDto extends WithName(WithEmail(WithPassword(class {}))) {}

    • WithName означает присутствие name или, например, firstName + lastName (для краткости)?

    • Когда полей будет с десяток, получится LISPообразная вязанка. Разобрать её визуально будет сложно.

    • У класса из N полей N+2 закрывающих скобок почти подряд. Обычно - одна.

    • DRY номинально есть, но по факту копипаст тот ещё.

    • Нормальная идиоматичная для TS композиция покинула чат.

    • Ctrl+Click будет перекидывать в какое-то изолированное объявление поля (файл with-x). Find all references тоже вряд ли выдаст все неабстрактные классы с этим полем.

    Может, просто декораторы композировать? Ну там IsPassword сделать какой-нибудь, если Nest это поймёт.


    1. TTT17
      13.05.2025 18:41

      Претензии, конечно, на уровне не знаю даже кого, нытика, наверное?

      Вот та же претензия про Name - кто тебе мешает нормальный нейминг делать? Или WithFirstName, например, или добавлять jsdoc комменты? Никто

      Кто тебе мешает кастомный рул для линтера написать, чтобы форматировать вязанку, как тебе нравится? Никто

      Ну и короче так по каждому пункту можно пройтись.

      По факту подход хороший, его просто надо адаптировать под себя, что тоже нормально.

      По поводу композиции тоже хорошая идея. Но тут всегда есть риски, что придется декомпоизировать какой-то декоратор на 2 разных. Учитывая определенные риски, то имеет место быть


      1. YegorP
        13.05.2025 18:41

        О да, я бы не просто заныл... Я бы даже завыл увидев как "with? name" повторяется семь раз по двум файлам ради одного поля.

        // dto/fields/with-name.dto.ts
        import { IsString, Length } from 'class-validator';
        
        export function WithName<T extends new (...args: any[]) => any>(Base: T) {
            abstract class WithName extends Base {
                @IsString()
                @Length(2, 50)
                name: string;
            }
            return WithName;
        }
        
        // dto/create-user.dto.ts
        import { WithName } from './fields/with-name.dto';
        
        export class CreateUserDto extends WithName(class {}) {}

        вместо

        import { IsString, Length } from 'class-validator';
        
        export class CreateUserDto {
            @IsString()
            @Length(2, 50)
            name: string;
        }

        Кастомные рулы линтера и прочий суперклей применимы к традиционному подходу тоже.


  1. Sayyat
    13.05.2025 18:41

    Нет опыта в бекенде через Nest Js, Но выглядит красиво. Есть кто пишет на Next js?


    1. olivera507224
      13.05.2025 18:41

      Красиво? Серьёзно?)


  1. Froncast
    13.05.2025 18:41

    Omg, что мешает просто использовать

    import { OmitType, PartialType } from '@nestjs/mapped-types'

    И тогда не будет речи о том чтобы дублировать поля в разных файлах/классах, а так же есть возможность их переопределить


  1. arutune
    13.05.2025 18:41

    Есть такое, self-describe code. Это когда код говорит "я Дартаньян, а ты г..но", как этот)))


  1. kemsky
    13.05.2025 18:41

    Логичнее делать атрибуты/аннотацию/декораторы, которые комбинируют ограничения, параметры определенных логических типов данных. Например, у вас есть поле телефон, заводите аннотацию Телефон, которая уже в себе содержит и регесп и максимальную длину и прочее. Не знаю насколько это возможно в конкретном фреймворке.


  1. AlekseyFront
    13.05.2025 18:41

    Я оставляю этот вопрос интерфейсам.

    От них уже создаю dto, используя интерфейсы как в чистом виде, так и пики, partal...

    Так красивее получается.