Choosing a DTO Validation Strategy in NestJS: class-validator vs Joi
A decision guide for NestJS DTO validation: when class-validator's decorator-based ValidationPipe is the right default, when Joi's schema power justifies a custom pipe, and how to implement and verify both.
18 Sept 2025, 23:45 UTC

Every NestJS API eventually faces the same decision: how do you reject malformed request bodies before they reach your business logic? The framework supports two mature approaches — decorator-based validation with class-validator and schema-based validation with Joi. The short version: pick class-validator if your DTOs are TypeScript classes and you want minimal wiring; pick Joi when you need complex conditional rules or already validate non-class payloads. The rest of this guide explains the trade-offs and shows both implementations.
The decision and its constraints
A DTO (Data Transfer Object) defines the shape of data crossing your API boundary. Validation constraints that usually drive the choice:
- Type safety: do you want the validation rules attached to the same class TypeScript compiles against?
- Error clarity: can clients get a structured list of every failure, not just the first?
- Boilerplate: how much duplicate schema definition are you willing to maintain?
- Rule complexity: do you need conditionals like \"field B required only when field A equals X\"?
Comparing the supported options
| Feature | class-validator | Joi |
|---|---|---|
| Syntax | Decorators on DTO classes | Standalone schema objects |
| TypeScript integration | Strong, via reflect-metadata | Manual; schema and type can drift |
| Error output | ValidationError array via ValidationPipe | Joi error with details list |
| Conditional rules | Verbose (custom decorators) | First-class (Joi.alternatives, .when()) |
| NestJS integration | Built-in ValidationPipe support | Requires a custom pipe |
| Best fit | Standard request DTOs | Config objects, polymorphic payloads |
Trade-offs in practice
class-validator is the default choice in the NestJS ecosystem for a reason: the global ValidationPipe picks up your decorators automatically, and class-transformer converts plain JSON into class instances so types line up. The cost is a hard dependency on reflect-metadata — if \"emitDecoratorMetadata\": true is missing from tsconfig.json, decorators silently do nothing at runtime. Complex conditional validation also gets awkward; you end up writing custom decorators for rules Joi expresses in one line.
Joi shines when validation logic is genuinely dynamic: discriminated unions, cross-field dependencies, or validating configuration objects that are not classes at all. The cost is duplication — you maintain a Joi schema and a TypeScript type, and nothing stops them from drifting apart. You also write the pipe yourself.
Implementation: class-validator with the global pipe
Install the packages and register the pipe once in main.ts (run in your project root; no special permissions needed):
npm install class-validator class-transformer
// main.ts\nimport { ValidationPipe } from '@nestjs/common';\n\nasync function bootstrap() {\n const app = await NestFactory.create(AppModule);\n app.useGlobalPipes(\n new ValidationPipe({\n whitelist: true, // strip unknown properties\n forbidNonWhitelisted: true, // reject instead of silently stripping\n transform: true, // convert payloads to DTO instances\n }),\n );\n await app.listen(3000);\n}
// create-user.dto.ts\nimport { IsEmail, IsString, MinLength } from 'class-validator';\n\nexport class CreateUserDto {\n @IsEmail()\n email: string;\n\n @IsString()\n @MinLength(8)\n password: string;\n}
Implementation: a custom Joi pipe
npm install joi\nnpm install -D @types/joi
// joi-validation.pipe.ts\nimport { PipeTransform, Injectable, BadRequestException } from '@nestjs/common';\nimport { ObjectSchema } from 'joi';\n\n@Injectable()\nexport class JoiValidationPipe implements PipeTransform {\n constructor(private schema: ObjectSchema) {}\n\n transform(value: any) {\n const { error, value: validated } = this.schema.validate(value, {\n abortEarly: false, // collect every failure, not just the first\n });\n if (error) {\n throw new BadRequestException(\n error.details.map((d) => ({ field: d.path.join('.'), message: d.message })),\n );\n }\n return validated;\n }\n}
Apply it at the route level, since Joi pipes are not global by default:
@Post()\n@UsePipes(new JoiValidationPipe(createUserSchema))\ncreate(@Body() body: CreateUserDto) { ... }
Verify the behavior
Start the app (npm run start:dev) and send an invalid payload:
curl -X POST http://localhost:3000/users \\\n -H \"Content-Type: application/json\" \\\n -d '{\"email\":\"not-an-email\",\"password\":\"short\"}'
With class-validator, expect a 400 response whose message is an array of strings like \"email must be an email\". With the Joi pipe, expect a 400 whose body is your mapped details array. If you get a 201 or 500 instead, check that emitDecoratorMetadata is enabled (class-validator) or that the pipe is actually applied to the route (Joi). Compare both error shapes and confirm your API consumers can parse the one you ship.
Limitations
Bundle-size differences between the two libraries are small enough that they should rarely drive the decision; maintainability should. Examples here assume NestJS 9/10 with class-validator 0.14 and Joi 17 — check the current docs if you are on a newer major. Neither approach validates data that bypasses the pipe, such as values read directly from headers or query strings handled outside DTO binding.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.