NestJS – DTOs and Validation

July 25, 20268 min readUpdated 9/5/2026

A DTO is a class that describes what a request body is allowed to contain. In Nest it does two jobs at once: it is the TypeScript type your handler receives, and — through decorators — it is the validation rule set applied before your handler runs.

It only does the second job if one line exists in main.ts. An application with beautifully annotated DTOs and no global pipe is completely unvalidated, and nothing about it looks wrong. That is the most important sentence in this lesson.

A DTO

export class RegisterDto {
  @IsEmail({}, { message: 'Enter a valid email address.' })
  @MaxLength(255)
  email: string

  @IsString()
  @MinLength(8, { message: 'Use a password of at least 8 characters.' })
  @MaxLength(72, { message: 'Passwords are limited to 72 characters.' })
  password: string

  @IsString()
  @Length(1, 80)
  firstName: string

  @IsString()
  @Length(1, 80)
  lastName: string

  @IsOptional()
  @IsString()
  @MaxLength(40)
  phone?: string

  @IsIn([UserRole.HOMEOWNER, UserRole.CONTRACTOR], {
    message: 'Pick whether you are a homeowner or a contractor.',
  })
  role: typeof UserRole.HOMEOWNER | typeof UserRole.CONTRACTOR
}

The decorators come from class-validator. Each attaches a rule to a property, and the optional second argument replaces the library's default message with one you would show a user.

Two of those rules are doing more than they appear to.

@MaxLength(72) on the password is not an arbitrary round number. bcrypt truncates its input at 72 bytes, so without a cap two different long passwords sharing a 72-byte prefix are the same password as far as the hash is concerned. Some bcrypt builds now throw on longer input instead, which turns a silly password into a 500. Rejecting it here makes it a clean 400.

@IsIn on the role is the security boundary of the whole sign-up flow. The role is the one field in this body that grants power, so it is the one field an attacker will try — and the list deliberately omits the staff role. There is no code path anywhere in this application that creates a privileged account from a request.

The line that makes it work

app.useGlobalPipes(
  new TrimPipe(),
  new ValidationPipe({
    whitelist: true,
    forbidNonWhitelisted: true,
    transform: true,
    transformOptions: {
      enableImplicitConversion: false,
    },
  }),
)

Those decorators are metadata. They do not run on their own; ValidationPipe is what reads them. Remove this call and every DTO in the application becomes documentation — the handler still receives dto.email, it is simply whatever the client sent.

The failure is silent in both directions, which is what makes it dangerous. Nothing logs a warning, no test fails unless a test specifically posts something invalid, and the endpoints all work.

The options are the interesting part

whitelist: true strips properties with no validation decorator. A request carrying {"role":"staff"} at a DTO with no role field arrives at the service without it. This is the option that turns your DTO into a genuine allowlist rather than a suggestion.

forbidNonWhitelisted: true goes further and rejects the request outright instead of quietly dropping the field. Louder, and it converts "why was my field ignored" into an error that names the field. Its value is visible in this application's own test suite, which asserts that posting a homeownerId is a 400 mentioning that property — proof that the actor cannot be set from the body.

transform: true runs class-transformer, which turns the plain parsed object into an actual instance of the DTO class. Without it your handler receives an object shaped like a RegisterDto that is not one — and, more practically, any @Type() decorator does nothing.

enableImplicitConversion: false keeps that conversion explicit. With it on, class-transformer coerces values to match the declared property types everywhere, which means "abc" quietly becoming NaN for a number field. Off, conversion happens only where a @Type decorator asks for it.

@Type, and when you need it

@Type(() => Number)
@IsNumber({}, { message: 'Enter a budget in whole dollars.' })
@Min(0)
budgetMin: number

A JSON body already carries numbers, so for a JSON API this looks redundant. It stops being redundant the moment a request arrives form-encoded or the value comes from a query string, where everything is a string — @IsNumber() would reject "1800". @Type converts before validation runs.

The opposite decision is worth seeing too:

@IsISO8601({ strict: true }, { message: 'Pick a start date.' })
preferredStartDate: string

Validated as a date string and kept as one. Declaring it Date would have class-transformer parse "2026-09-10" as UTC midnight, and Postgres would then store whatever local day that lands on — reintroducing exactly the timezone bug the date column type exists to avoid. The rule of thumb: convert when the wire format is lossy, and not when the string is already the value you want.

Nested objects and arrays

Validation does not recurse by default, and the failure is quiet. A DTO with an object property validates that the property exists and nothing about what is inside it:

class CreateThingDto {
  // The wrong way: `address` is checked for being an object and no further.
  // Every rule inside AddressDto is skipped, silently.
  @IsObject()
  address: AddressDto
}

Making it recurse takes two decorators, and both are required: @ValidateNested() tells class-validator to descend, and @Type() tells class-transformer which class to build — without it there is no instance carrying the metadata to validate against.

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

@ValidateNested({ each: true })
@Type(() => LineItemDto)
items: LineItemDto[]

Arrays of primitives have a simpler form — @IsUUID('4', { each: true }) applies the rule to every element. This application uses that for the list of trades on a contractor profile, and then checks in the service that the ids resolve to real categories, because "is a UUID" and "exists" are different questions and only one of them is answerable here.

Reusing DTOs without repeating them

An update DTO is usually a create DTO with everything optional, and copying the class to add @IsOptional() everywhere leaves two definitions that drift. Nest ships mapped types for this:

export class UpdateProjectDto extends PartialType(CreateProjectDto) {}

// also: PickType, OmitType, IntersectionType
export class ProjectSummaryDto extends PickType(CreateProjectDto, ['title', 'city'] as const) {}

PartialType copies every property and its validation rules, then adds @IsOptional() to each. A rule added to the parent appears in the child automatically, which is the point.

Use them where the relationship is genuinely "the same fields, weaker requirements". Where an update legitimately accepts a different set of fields from a create — as it does in this application, because a contractor profile is created by the system and only ever updated by its owner — two explicit classes say more than a derived one.

What validation cannot express

The limit is easy to state: a decorator sees one property, and knows nothing about the other properties, the database, or who is asking.

if (dto.budgetMax < dto.budgetMin) {
  throw new BadRequestException('The top of the budget cannot be below the bottom of it.')
}

That comparison needs two fields, so it lives in the service. So does every rule that needs a query — that the category exists, that the project still accepts bids, that this contractor works in that trade. The DTO's job is to guarantee the shape; the service's job is to decide whether the request makes sense.

Drawing that line consistently is what keeps DTOs readable. When a validation decorator starts needing context, it has stopped being validation.

Response DTOs are a separate idea

A request DTO says what may come in. Nothing about it says what goes out, and using an entity for that is how a password hash ends up in a response.

This application maps explicitly on the way out:

export function toUserSummaryDto(user: User) {
  return {
    id: user.publicId,
    firstName: user.firstName,
    lastName: user.lastName,
    avatarUrl: user.avatarUrl,
  }
}

Nothing that renders a byline needs an email address, so nothing that renders one is given it. The mappers are plain functions rather than classes because they have no dependencies — and being plain functions, the compiler checks them.

The alternative Nest offers is ClassSerializerInterceptor plus @Exclude() on entity fields, which is less code. It also inverts the default: every column is public unless someone remembered to mark it. Both are defensible; for a table with a password hash in it, the allowlist is the safer default.

Error messages are user interface

Every rule in RegisterDto carries a custom message, and that is deliberate rather than decorative. The library's defaults are accurate and unusable:

role must be one of the following values: homeowner, contractor
password must be longer than or equal to 8 characters

versus what this application says:

Pick whether you are a homeowner or a contractor.
Use a password of at least 8 characters.

The first pair leaks the property names and the enum values to whoever is probing; the second tells a person what to do. Since the messages arrive as an array in the response body, a form can show them directly — so writing them badly means either shipping developer text to users, or maintaining a second copy of every message in the client.

Not every rule needs one. The DTOs here leave @Length(1, 80) on a first name with its default, because that field's failure mode is obvious and the message is only ever seen by someone hand-crafting a request. Spend the effort on the rules a real user will hit.

One more option worth knowing: disableErrorMessages: true strips the messages from responses entirely. It is sometimes recommended for production on the grounds that messages leak property names. Weigh it against the cost — a client that can no longer tell a user which field is wrong — and prefer writing messages that are safe to show over hiding all of them.

Validation as a spec

The habit worth forming is treating the DTO as the definition of the endpoint's contract. Read RegisterDto and you know exactly what a sign-up accepts, what it rejects, and what a user sees when they get it wrong — without reading the service, the controller or the tests.

That only holds if the rules are complete. A field with a type annotation and no decorator is worse than useless under whitelist: true: it is silently stripped, so the handler receives undefined and the reason is invisible. Every property in a DTO wants at least one decorator, and @IsOptional() is how you say a property may be absent without giving up the rules that apply when it is present.

Next: pipes — what ValidationPipe actually is, the built-in ones worth knowing, and a custom pipe written to fix a real bug in the DTOs above.