Всем привет! Наткнулся недавно на статью Переосмысление 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.: Это моя первая статья на Хабре, буду рад любой конструктивной критике :-)