Schema Validation with Zod & Joi
NestJS ships with class-validator as its default validation strategy, but it is far from the only option. Schema-first libraries like Zod and Joi let you describe the shape of your data as a single declarative object rather than decorating class properties. Because NestJS validation is just a pipe, you can wrap any schema library in a custom pipe and gain end-to-end type inference, smaller DTOs, and runtime guarantees that match your TypeScript types exactly.
Why reach for a schema library?
class-validator works by attaching decorators to a class, then reflecting over them at runtime. That is ergonomic, but the validation rules and the TypeScript type live in two places that can drift apart. Schema libraries flip this around: the schema is the source of truth, and the static type is derived from it.
| Concern | class-validator | Zod | Joi |
|---|---|---|---|
| Source of truth | Decorated class | Schema object | Schema object |
| Type inference | Manual (class doubles as type) | Automatic via z.infer | None (separate interface) |
| Transformation | class-transformer | Built-in (.transform, coercion) | Built-in (.default, conversions) |
| Bundle weight | Medium | Light | Medium |
| Ecosystem fit | First-class in NestJS | nestjs-zod adapter | Manual pipe |
Zod is the strongest fit for TypeScript projects because a single schema produces both the runtime validator and the compile-time type. Joi remains popular for teams already invested in the Hapi ecosystem.
Building a Zod validation pipe
A validation pipe receives the incoming value and either returns it (possibly transformed) or throws. Wrapping Zod is only a handful of lines.
import {
PipeTransform,
Injectable,
ArgumentMetadata,
BadRequestException,
} from '@nestjs/common';
import { ZodSchema } from 'zod';
@Injectable()
export class ZodValidationPipe implements PipeTransform {
constructor(private readonly schema: ZodSchema) {}
transform(value: unknown, _metadata: ArgumentMetadata) {
const result = this.schema.safeParse(value);
if (!result.success) {
throw new BadRequestException({
message: 'Validation failed',
errors: result.error.flatten().fieldErrors,
});
}
return result.data;
}
}
Define the schema and infer the DTO type from it so the two never diverge:
import { z } from 'zod';
export const createUserSchema = z
.object({
email: z.string().email(),
password: z.string().min(8),
age: z.coerce.number().int().min(18),
})
.required();
export type CreateUserDto = z.infer<typeof createUserSchema>;
Apply the pipe at the parameter level, passing the schema to the constructor:
import { Body, Controller, Post, UsePipes } from '@nestjs/common';
import { ZodValidationPipe } from './zod-validation.pipe';
import { createUserSchema, CreateUserDto } from './create-user.schema';
@Controller('users')
export class UsersController {
@Post()
@UsePipes(new ZodValidationPipe(createUserSchema))
create(@Body() dto: CreateUserDto) {
return { id: 'usr_1', email: dto.email, age: dto.age };
}
}
A request with a bad payload returns a structured 400:
Output:
{
"statusCode": 400,
"message": "Validation failed",
"errors": {
"email": ["Invalid email"],
"age": ["Number must be greater than or equal to 18"]
}
}
Using nestjs-zod for inference and DTO classes
The nestjs-zod package removes the boilerplate above. It provides a ready-made ZodValidationPipe, a createZodDto helper that turns a schema into a class usable with @Body(), and Swagger integration.
npm install nestjs-zod zod
import { createZodDto } from 'nestjs-zod';
import { z } from 'zod';
const CreateUserSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
age: z.coerce.number().int().min(18),
});
export class CreateUserDto extends createZodDto(CreateUserSchema) {}
Register the pipe globally so every createZodDto class is validated automatically:
import { Module } from '@nestjs/common';
import { APP_PIPE } from '@nestjs/core';
import { ZodValidationPipe } from 'nestjs-zod';
@Module({
providers: [{ provide: APP_PIPE, useClass: ZodValidationPipe }],
})
export class AppModule {}
Now controllers stay clean — the DTO class carries both the type and the validation rules:
@Post()
create(@Body() dto: CreateUserDto) {
return { email: dto.email };
}
Building a Joi validation pipe
Joi has no native TypeScript inference, so you typically maintain a separate interface alongside the schema. The pipe pattern is identical to Zod.
import {
PipeTransform,
Injectable,
ArgumentMetadata,
BadRequestException,
} from '@nestjs/common';
import { ObjectSchema } from 'joi';
@Injectable()
export class JoiValidationPipe implements PipeTransform {
constructor(private readonly schema: ObjectSchema) {}
transform(value: unknown, _metadata: ArgumentMetadata) {
const { error, value: validated } = this.schema.validate(value, {
abortEarly: false,
stripUnknown: true,
});
if (error) {
throw new BadRequestException(
error.details.map((d) => d.message),
);
}
return validated;
}
}
import * as Joi from 'joi';
export const createUserSchema = Joi.object({
email: Joi.string().email().required(),
password: Joi.string().min(8).required(),
age: Joi.number().integer().min(18).required(),
});
export interface CreateUserDto {
email: string;
password: string;
age: number;
}
The abortEarly: false option collects every error instead of stopping at the first, and stripUnknown: true discards properties not declared in the schema — a quick way to prevent mass-assignment.
Schema pipes only see the raw
@Body(),@Query(), or@Param()value. They do not run nested validation across multiple decorators automatically, so validate each parameter with its own schema or compose a single object schema for the whole body.
Best Practices
- Derive your TypeScript type from the schema (
z.infer) rather than declaring it twice — this is Zod’s biggest advantage over Joi. - Prefer
safeParseoverparsein Zod pipes so you control the thrown exception and response shape instead of leaking a rawZodError. - Use
z.coerce/ Joi conversions for query and param values, which arrive as strings, instead of validating them as numbers directly. - Register the pipe globally with
APP_PIPEfor app-wide consistency, and reserve@UsePipes(new ...)for endpoint-specific schemas. - Enable
stripUnknown(Joi) or.strict()/.strip()(Zod) to drop unexpected fields and guard against over-posting. - Pick one approach per codebase — mixing
class-validatorand schema pipes works but fragments how errors are shaped and reported.