Всем привет! Наткнулся недавно на статью Переосмысление 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)
ggdev
13.05.2025 18:41extends WithName(WithEmail(WithPassword(class {})))
А почему бы не использовать IntersectionType()?
anikitinhabr
13.05.2025 18:41https://docs.nestjs.com/openapi/mapped-types
/
IntersectionType
Он в том числе с метаданными сваггер-декораторов полей нормально отрабатывает, перетаскивает их в результирующий класс.
shagor Автор
13.05.2025 18:41Как я уже ответил, да, можно использовать и IntersectionType. Я больше в целом о подходе собирать DTO из набора полей, на сколько это удобно или нет, в тех или иных случаях, и какие для этого есть средства в том же NestJS. IntersectionType сам не использовал.
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
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 это поймёт.TTT17
13.05.2025 18:41Претензии, конечно, на уровне не знаю даже кого, нытика, наверное?
Вот та же претензия про Name - кто тебе мешает нормальный нейминг делать? Или WithFirstName, например, или добавлять jsdoc комменты? Никто
Кто тебе мешает кастомный рул для линтера написать, чтобы форматировать вязанку, как тебе нравится? Никто
Ну и короче так по каждому пункту можно пройтись.
По факту подход хороший, его просто надо адаптировать под себя, что тоже нормально.По поводу композиции тоже хорошая идея. Но тут всегда есть риски, что придется декомпоизировать какой-то декоратор на 2 разных. Учитывая определенные риски, то имеет место быть
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; }
Кастомные рулы линтера и прочий суперклей применимы к традиционному подходу тоже.
Sayyat
13.05.2025 18:41Нет опыта в бекенде через Nest Js, Но выглядит красиво. Есть кто пишет на Next js?
Froncast
13.05.2025 18:41Omg, что мешает просто использовать
import { OmitType, PartialType } from '@nestjs/mapped-types'
И тогда не будет речи о том чтобы дублировать поля в разных файлах/классах, а так же есть возможность их переопределить
arutune
13.05.2025 18:41Есть такое, self-describe code. Это когда код говорит "я Дартаньян, а ты г..но", как этот)))
kemsky
13.05.2025 18:41Логичнее делать атрибуты/аннотацию/декораторы, которые комбинируют ограничения, параметры определенных логических типов данных. Например, у вас есть поле телефон, заводите аннотацию Телефон, которая уже в себе содержит и регесп и максимальную длину и прочее. Не знаю насколько это возможно в конкретном фреймворке.
AlekseyFront
13.05.2025 18:41Я оставляю этот вопрос интерфейсам.
От них уже создаю dto, используя интерфейсы как в чистом виде, так и пики, partal...
Так красивее получается.
TsarS
Почему то всегда думал, что DTO просто для переноса данных, а валидация должна быть в хендлерах
MyraJKee
Абсолютно так же мысль возникла и вопрос - неужели так принято в nestjs?