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: numberA 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: stringValidated 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 charactersversus 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.