Validation
What we’re building
Section titled “What we’re building”A global ValidationPipe in main.ts, with whitelist, transform, and forbidNonWhitelisted all enabled. Alongside it, the first class-validator DTO in the app — apps/api/src/users/dto/create-user.dto.ts — plus a minimal UsersModule (controller and service) whose only job is to give that DTO a real POST endpoint to validate against, so the pipe’s behavior is something you can curl and see, not just read about.
class-validator decorators (@IsEmail(), @MinLength(8)) on a DTO class describe validation rules declaratively, next to the field they apply to — the same pattern @Prop() used for Mongoose schema fields in Schemas. On their own, those decorators do nothing; something has to read the metadata they attach and actually run the checks. That’s ValidationPipe, and registering it once with useGlobalPipes in main.ts means every controller in the app gets DTO validation for free — no controller method has to call anything explicitly, the same way every future controller gets AllExceptionsFilter’s error handling for free from Config & exceptions without adding a line to itself.
Each option does one specific job:
whitelist: true— strips any property on the incoming body that isn’t declared on the DTO class. A request with an extraisAdmin: truefield silently loses that field before it reaches the controller method.transform: true— converts the plain JSON object Express parses into an actual instance of the DTO class (CreateUserDto, not just an object shaped like one), and coerces primitive types (a query-string"42"becomes the number42if the DTO field is typednumber). Without it,@Body() dto: CreateUserDtois a DTO-shaped object that failsinstanceof CreateUserDtoand hasn’t had any type coercion applied.forbidNonWhitelisted: true— the stricter sibling ofwhitelist. Instead of silently dropping an unknownisAdmin: true, the whole request is rejected with a400naming the unexpected property.whitelistalone is a cleanup step;forbidNonWhitelistedturns “you sent something we don’t recognize” into a loud, visible error instead of a client silently believing a field it sent was accepted.
Pros & cons
Section titled “Pros & cons”Validating at the transport edge (DTO + global pipe) vs. validating inside the service/domain layer. A global ValidationPipe rejects a malformed request before any handler code runs — no service method, no database query, no business logic executes on bad input, and every endpoint gets the same guarantee for free. The trade-off is that these rules live in class-validator decorators tied to the HTTP transport layer, not in the domain model itself: a UsersService method called from somewhere other than an HTTP request (a seed script, a queue consumer added later) gets none of this validation unless it also goes through a ValidationPipe.transform() call or its own explicit checks. For DevBlog, every write currently does go through either a REST controller or, from GraphQL API onward, a GraphQL resolver — both transport layers where this same edge-validation approach applies — so the gap stays theoretical for this course, but it’s worth naming: edge validation protects entry points, not the domain layer itself.
Reuse in GraphQL API. class-validator decorators aren’t REST-specific — the same CreateUserDto class below is written so that adding a single @InputType() decorator to the class (and @Field() to each property) is enough to reuse it as a GraphQL input type in Module 5, validated by the identical global ValidationPipe, which NestJS’s GraphQL integration runs against resolver arguments the same way it runs against REST @Body(). Writing the DTO once, now, means Module 5 adds decorators to an existing class instead of writing a second, parallel validation definition.
Set it up
Section titled “Set it up”Update apps/api/src/main.ts to enable the global pipe:
import { NestFactory, HttpAdapterHost } from '@nestjs/core';import { ConfigService } from '@nestjs/config';import { ValidationPipe } from '@nestjs/common';import { AppModule } from './app.module';import { AllExceptionsFilter } from './common/filters/all-exceptions.filter';
async function bootstrap() { const app = await NestFactory.create(AppModule); const configService = app.get(ConfigService);
app.enableCors({ origin: configService.getOrThrow<string>('WEB_ORIGIN'), credentials: true, });
app.useGlobalPipes( new ValidationPipe({ whitelist: true, transform: true, forbidNonWhitelisted: true, }), );
const httpAdapterHost = app.get(HttpAdapterHost); app.useGlobalFilters(new AllExceptionsFilter(httpAdapterHost));
app.enableShutdownHooks();
const port = configService.get<number>('API_PORT') ?? 4000; await app.listen(port); console.log(`API listening on http://localhost:${port}`);}bootstrap();Create apps/api/src/users/dto/create-user.dto.ts:
import { IsEmail, IsString, MinLength } from 'class-validator';
export class CreateUserDto { @IsEmail() email: string;
@IsString() @MinLength(8) password: string;
@IsString() @MinLength(2) displayName: string;}password is validated here, but hashing it with bcrypt before it ever reaches a database is Authentication’s job, not this DTO’s — this lesson only proves the shape and the pipe wiring.
Create apps/api/src/users/users.service.ts. It has no persistence yet — that arrives with real user creation in Authentication — so it just echoes back the validated, transformed DTO to make the pipe’s effect visible:
import { Injectable } from '@nestjs/common';import { CreateUserDto } from './dto/create-user.dto';
@Injectable()export class UsersService { create(dto: CreateUserDto): CreateUserDto { return dto; }}Create apps/api/src/users/users.controller.ts:
import { Body, Controller, Post } from '@nestjs/common';import { UsersService } from './users.service';import { CreateUserDto } from './dto/create-user.dto';
@Controller('users')export class UsersController { constructor(private readonly usersService: UsersService) {}
@Post() create(@Body() dto: CreateUserDto): CreateUserDto { return this.usersService.create(dto); }}Create apps/api/src/users/users.module.ts:
import { Module } from '@nestjs/common';import { UsersController } from './users.controller';import { UsersService } from './users.service';
@Module({ controllers: [UsersController], providers: [UsersService],})export class UsersModule {}Register UsersModule in apps/api/src/app.module.ts, alongside PostsModule:
import { Module } from '@nestjs/common';import { ConfigModule, ConfigService } from '@nestjs/config';import { MongooseModule } from '@nestjs/mongoose';import { Logger } from '@nestjs/common';import { Connection } from 'mongoose';import * as Joi from 'joi';import { AppController } from './app.controller';import { AppService } from './app.service';import { PostsModule } from './posts/posts.module';import { UsersModule } from './users/users.module';
@Module({ imports: [ ConfigModule.forRoot({ isGlobal: true, envFilePath: '../../.env', validationSchema: Joi.object({ MONGODB_URI: Joi.string().uri().required(), JWT_SECRET: Joi.string().min(10).required(), API_PORT: Joi.number().port().default(4000), WEB_ORIGIN: Joi.string().uri().required(), }), validationOptions: { allowUnknown: true, abortEarly: false, }, }), MongooseModule.forRootAsync({ inject: [ConfigService], useFactory: (configService: ConfigService) => ({ uri: configService.getOrThrow<string>('MONGODB_URI'), onConnectionCreate: (connection: Connection) => { connection.on('connected', () => new Logger('MongooseModule').log('MongoDB connected'), ); return connection; }, }), }), PostsModule, UsersModule, ], controllers: [AppController], providers: [AppService],})export class AppModule {}Verify
Section titled “Verify”npm run start:devA valid request round-trips, with transform producing a real CreateUserDto instance:
curl -i -X POST localhost:4000/users \ -H "Content-Type: application/json" \ -d '{"email":"author@example.com","password":"correct-horse","displayName":"Ava"}'HTTP/1.1 201 Created{"email":"author@example.com","password":"correct-horse","displayName":"Ava"}An unrecognized field is rejected outright by forbidNonWhitelisted, instead of silently dropped:
curl -i -X POST localhost:4000/users \ -H "Content-Type: application/json" \ -d '{"email":"author@example.com","password":"correct-horse","displayName":"Ava","isAdmin":true}'HTTP/1.1 400 Bad Request{"statusCode":400,"timestamp":"...","path":"/users","message":["property isAdmin should not exist"]}An invalid field is caught by class-validator, and the message array comes from AllExceptionsFilter passing exception.getResponse() through unchanged:
curl -i -X POST localhost:4000/users \ -H "Content-Type: application/json" \ -d '{"email":"not-an-email","password":"short","displayName":"Ava"}'HTTP/1.1 400 Bad Request{"statusCode":400,"timestamp":"...","path":"/users","message":["email must be an email","password must be longer than or equal to 8 characters"]}ValidationPipe({ whitelist: true, transform: true, forbidNonWhitelisted: true }), registered once globally, gives every controller in the app DTO validation, unknown-property rejection, and real class instances in @Body() without any per-route code. CreateUserDto’s class-validator decorators are the declarative source of truth for those rules — and are written so GraphQL API can reuse the same class as a GraphQL input type with one added decorator, validated by this same global pipe. UsersModule here is intentionally thin (no persistence yet) — it exists to make the pipe’s three options each individually visible over curl, and Authentication builds the real user creation on top of it.
Next: Authentication →