DEV Community

Cover image for The DTO Problem in NestJS: Too Many Classes, Too Many Decorators
Artem M
Artem M

Posted on

The DTO Problem in NestJS: Too Many Classes, Too Many Decorators

Recommended reading: Moost without the ceremony — the NestJS comparison that sets up the context for this article.

Every NestJS project starts with a clean DTO. A name, an email, a password — three decorators, one class. It looks fine.

Then the product grows. Nested addresses, arrays of line items, conditional fields, update variants. Each nested shape needs its own class. Each property needs its own decorator stack. Each class needs its own file. What started as a clean validation layer becomes a parallel type system that you maintain by hand.

The problem is not decorators by themselves. The problem is duplicating the same truth across TypeScript types, DTO classes, and validation rules — and watching that duplication scale with every feature.

It starts simple enough

Here is a standard NestJS signup DTO. Nothing unusual:

import { IsString, IsEmail, IsNotEmpty, MinLength } from 'class-validator'

export class CreateUserDto {
  @IsString()
  @IsNotEmpty()
  name: string

  @IsEmail()
  @IsNotEmpty()
  email: string

  @IsString()
  @MinLength(8)
  password: string
}
Enter fullscreen mode Exit fullscreen mode

Three fields, six decorators. Already more annotation than logic, but manageable. This is the example every NestJS tutorial shows, and it looks clean because the payload is flat.

Real payloads are not flat.

Nested objects force you into a class-per-shape pattern

The moment a payload has a nested object — an address, a billing section, a list of items — class-validator requires a separate class for each one. There is no inline syntax. Every nested shape must be extracted, named, decorated, and wired up.

Here is a typical e-commerce order DTO:

import {
  IsString, IsInt, IsEmail, IsOptional,
  IsArray, ArrayNotEmpty, ValidateNested,
  Min, MinLength, Matches,
} from 'class-validator'
import { Type } from 'class-transformer'

export class OrderItemDto {
  @IsString()
  @MinLength(1)
  productId: string

  @IsInt()
  @Min(1)
  quantity: number
}

export class AddressDto {
  @IsString()
  street: string

  @IsString()
  city: string

  @IsString()
  @Matches(/^[0-9]{5}$/)
  zip: string
}

export class CreateOrderDto {
  @IsEmail()
  customerEmail: string

  @IsArray()
  @ArrayNotEmpty()
  @ValidateNested({ each: true })
  @Type(() => OrderItemDto)
  items: OrderItemDto[]

  @ValidateNested()
  @Type(() => AddressDto)
  shipping: AddressDto

  @IsOptional()
  @ValidateNested()
  @Type(() => AddressDto)
  billing?: AddressDto
}
Enter fullscreen mode Exit fullscreen mode

Three classes, eighteen decorators, two package imports. And this is still a simple order — no discount rules, no gift options, no metadata.

Notice the wiring tax. @ValidateNested() alone does nothing — you must pair it with @Type(() => ClassName) from class-transformer, otherwise validation silently skips the nested object. For arrays, add { each: true }. For optional nested fields, stack @IsOptional() on top. Forget any of these and validation breaks without a clear error.

This is not a bug in class-validator. It is the design. The library validates class instances, so every nested shape must be a class. TypeScript's structural typing does not help here — class-validator needs runtime class identity.

Now add Swagger — and the decorators triple

decorator explosion

In real NestJS projects, DTOs do not just validate. They also generate API documentation. That means every property needs Swagger decorators too:

import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'
import { IsString, IsEmail, IsNotEmpty, IsOptional, MinLength } from 'class-validator'

export class CreateUserDto {
  @ApiProperty({ description: 'Full name', example: 'Jane Doe' })
  @IsString()
  @IsNotEmpty()
  name: string

  @ApiProperty({ description: 'Email address', example: 'jane@example.com' })
  @IsEmail()
  @IsNotEmpty()
  email: string

  @ApiProperty({ description: 'Password (min 8 chars)', example: 'securepass' })
  @IsString()
  @MinLength(8)
  password: string

  @ApiPropertyOptional({ description: 'Phone number' })
  @IsOptional()
  @IsString()
  phone?: string
}
Enter fullscreen mode Exit fullscreen mode

Four properties, twelve decorators. Each property declares its truth three times: once as a TypeScript type, once for validation, once for documentation. The NestJS Swagger CLI plugin can infer some @ApiProperty() annotations automatically, but it does not remove the need for validation decorators — and once you need custom descriptions, examples, or explicit types, you are back to manual annotation.

This is the everyday reality of a NestJS codebase at scale. Not the three-field tutorial example — the fifty-DTO project where every API change means editing decorator stacks across multiple files.

Update DTOs: same shape, different optionality

NestJS provides PartialType() to derive update DTOs from create DTOs:

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

export class UpdateUserDto extends PartialType(CreateUserDto) {}
Enter fullscreen mode Exit fullscreen mode

This works for flat objects. But for nested payloads — where you want to partially update an address inside an order — PartialType() only makes top-level fields optional. The nested AddressDto still requires all its fields. You end up with separate UpdateAddressDto, PatchOrderDto, and so on — more classes, more files, more maintenance.

class-validator has no concept of deep partial validation. Every variation of optionality requires a new class.

Primitive validation does not exist

Here is something class-validator cannot do: validate a standalone value that is not a class property.

Want a reusable "validated email" type that you can use across DTOs and as a direct handler argument? In class-validator, you cannot. Every validated value must live inside a class:

// This is the only way to validate a single email
class EmailDto {
  @IsEmail()
  value: string
}

// You cannot validate a bare string
// You cannot reuse "validated email" as a type
Enter fullscreen mode Exit fullscreen mode

This means you cannot build a vocabulary of validated primitives — Email, PositiveInt, UUID — and compose them. Every DTO must redeclare the same @IsEmail(), @IsInt(), @Min(0) stacks on every property where the rule applies. The duplication is baked into the design.

The real cost is maintenance

maintenance burden

None of these problems matter in a three-endpoint app. They matter at scale.

A mid-size NestJS API — fifty endpoints, nested payloads, Swagger docs — easily has a hundred DTO classes. Each one is a hand-maintained bridge between the TypeScript type you think you have and the runtime validation that actually runs. Each one can drift. Each one is a place where a missing @Type() or a forgotten @IsOptional() silently breaks validation.

The community knows this. Libraries like nest-dto and nestjs-swagger-dto exist specifically to reduce the decorator tax by composing multiple decorators into one. The NestJS team added a Swagger CLI plugin to auto-infer some annotations. Developers have written articles titled "Why you should not use class-validator in NestJS". These are all symptoms of the same underlying design constraint.

What if the type was the validation?

class-validator bridges this gap with decorators. Zod bridges it with a schema DSL. Both work, but both require a separate description of data that your TypeScript types already express.

Atscript takes a different approach: validation is part of the type definition itself.

Here is the same order payload:

// order.as
export interface OrderItem {
  @expect.minLength 1
  productId: string
  @expect.min 1
  quantity: number.int
}

export interface Address {
  street: string
  city: string
  @expect.pattern "^[0-9]{5}$"
  zip: string
}

export interface CreateOrder {
  email: string.email
  items: OrderItem[]
  shipping: Address
  billing?: Address
}
Enter fullscreen mode Exit fullscreen mode

One file. No @IsString() on things already typed as string. No @ValidateNested() + @Type() wiring. No class-transformer import. Nested structures validate automatically because the type already describes them. Optional fields are optional because of ?, not because of an @IsOptional() decorator.

The number.int and string.email are semantic types — they carry built-in validation constraints. You do not annotate what the type system already expresses.

Reusable validated primitives

Atscript lets you define validated types once and use them everywhere:

export type Email = string.email
export type PositiveInt = number.int & number.positive
Enter fullscreen mode Exit fullscreen mode

These are not wrapper classes. They are types that validate. Use them as property types, function parameters, or compose them with &:

import { Email } from './types.as'

@Controller('newsletter')
export class NewsletterController {
  @Post()
  async subscribe(@Body() email: Email) {
    // email is validated automatically
    return this.newsletter.subscribe(email)
  }
}
Enter fullscreen mode Exit fullscreen mode

A single Email type, used directly as a handler argument, validated at runtime by the framework. No DTO class, no decorator stack, no wrapper.

Deep partial validation without extra classes

Remember the update DTO problem? Atscript handles it with a validator option, not a new class:

// Top-level partial — missing required fields are OK
CreateOrder.validator({ partial: true }).validate(data)

// Deep partial — nested fields are optional too
CreateOrder.validator({ partial: 'deep' }).validate(data)

// Custom — partial only for specific paths
CreateOrder.validator({
  partial: (type, path) => path.startsWith('shipping'),
}).validate(data)
Enter fullscreen mode Exit fullscreen mode

One model, multiple validation modes. No UpdateOrderDto, no PatchAddressDto, no class hierarchy.

For context: Zod also had deep partial support, but deprecated .deepPartial() in v3 and removed it in v4 with no built-in replacement — a pain point with over 100 reactions from the community. class-validator never had the concept at all.

This is not just another validator

The examples so far compare validation syntax. But the real difference is scope.

class-validator validates. Zod validates. Atscript describes. The same .as file that carries validation constraints also carries labels for UI, database annotations, API documentation metadata — anything your model needs to express. One definition, shared across the stack.

That is the topic for the next article: what happens when the model becomes the source of truth for your entire application, not just the validation layer.


If your NestJS project has more DTO files than feature files, the tool is not the problem — the pattern is. The next article steps back from validation entirely and looks at what Atscript actually is: a model-first type language that makes validation just one of many outputs from a single definition.


Read next: Atscript: model-first — What happens when the model becomes the source of truth for types, validation, database schema, and UI metadata.

Top comments (0)