Skip to content
NestJS ns pipes 4 min read

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.

Concernclass-validatorZodJoi
Source of truthDecorated classSchema objectSchema object
Type inferenceManual (class doubles as type)Automatic via z.inferNone (separate interface)
Transformationclass-transformerBuilt-in (.transform, coercion)Built-in (.default, conversions)
Bundle weightMediumLightMedium
Ecosystem fitFirst-class in NestJSnestjs-zod adapterManual 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 safeParse over parse in Zod pipes so you control the thrown exception and response shape instead of leaking a raw ZodError.
  • 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_PIPE for 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-validator and schema pipes works but fragments how errors are shaped and reported.
Last updated June 14, 2026
Was this helpful?