Custom Pipes & Advanced Validation
Implement PipeTransform for domain-specific parsing, and drive schema validation (Zod/Joi) through a pipe.
Built-in pipes cover primitives, but real apps validate domain shapes: a valid slug, a well-formed cursor, a body matching a Zod schema. A custom pipe is a tiny class implementing PipeTransform.
The contract
transform(value, metadata) receives the incoming value and its ArgumentMetadata (type, metatype, param name). Return the transformed value, or throw a BadRequestException to reject it:
@Injectable()
export class TrimPipe implements PipeTransform {
transform(value: unknown) {
return typeof value === 'string' ? value.trim() : value;
}
}Schema validation through a pipe
The most common senior pattern is wrapping a schema validator (Zod, Joi, Yup) in a pipe. The pipe parses the body, and on failure maps the validator's error into a clean 400. This gives you runtime type-safety that class-validator's decorators can't always express - discriminated unions, refinements, transforms.
Where to bind it
- Per parameter:
@Body(new ZodValidationPipe(schema))- most explicit. - Per handler/controller:
@UsePipes(...). - Globally:
app.useGlobalPipes(...)for cross-cutting rules.
Pipes vs guards vs interceptors
Keep the boundaries clean: a pipe shapes and validates a single argument, a guard decides access, an interceptor wraps the whole handler. When a pipe starts making auth decisions, it's in the wrong layer.
Example
import {
PipeTransform,
Injectable,
ArgumentMetadata,
BadRequestException,
} from '@nestjs/common';
import { z, ZodSchema } from 'zod';
// One reusable pipe for ANY Zod schema.
@Injectable()
export class ZodValidationPipe<T> implements PipeTransform<unknown, T> {
constructor(private readonly schema: ZodSchema<T>) {}
transform(value: unknown, _meta: ArgumentMetadata): T {
const result = this.schema.safeParse(value);
if (!result.success) {
throw new BadRequestException({
message: 'Validation failed',
issues: result.error.issues.map((i) => ({
path: i.path.join('.'),
error: i.message,
})),
});
}
return result.data;
}
}
// Schema is the single source of truth for shape AND type.
export const CreateOrderSchema = z
.object({
items: z.array(z.string().min(1)).nonempty(),
coupon: z.string().regex(/^[A-Z0-9]{4,10}$/).optional(),
priority: z.enum(['standard', 'express']).default('standard'),
})
.strict();
export type CreateOrderDto = z.infer<typeof CreateOrderSchema>;
// Usage in a controller:
// @Post()
// create(
// @Body(new ZodValidationPipe(CreateOrderSchema)) dto: CreateOrderDto,
// ) {
// return this.orders.create(dto);
// }When to use it
- A developer writes a ZodPipe that validates the request body against a Zod schema and throws BadRequestException with the formatted Zod error.
- A team creates a ParseObjectIdPipe that validates a route parameter is a valid MongoDB ObjectId and converts it to an ObjectId instance.
- An engineer builds a SanitizePipe that runs the input through DOMPurify before saving user-generated HTML content to the database.
More examples
Zod schema validation pipe
A generic pipe that accepts any Zod schema and throws a formatted BadRequestException when the value fails validation.
import { PipeTransform, Injectable, BadRequestException } from '@nestjs/common';
import { ZodSchema } from 'zod';
@Injectable()
export class ZodPipe implements PipeTransform {
constructor(private schema: ZodSchema) {}
transform(value: unknown) {
const result = this.schema.safeParse(value);
if (!result.success) {
throw new BadRequestException(result.error.format());
}
return result.data;
}
}
// Usage: @Body(new ZodPipe(createProductSchema)) dto: CreateProductDtoMongoDB ObjectId validation pipe
Validates and converts a route parameter string to a Mongoose ObjectId, returning 400 for invalid ID strings.
import { PipeTransform, Injectable, BadRequestException, NotFoundException } from '@nestjs/common';
import { Types } from 'mongoose';
@Injectable()
export class ParseObjectIdPipe implements PipeTransform<string, Types.ObjectId> {
transform(value: string): Types.ObjectId {
if (!Types.ObjectId.isValid(value)) {
throw new BadRequestException(`'${value}' is not a valid ObjectId`);
}
return new Types.ObjectId(value);
}
}Joi schema validation pipe
Runs the value against a Joi schema and collects all validation errors in a single pass using abortEarly: false before throwing.
import { PipeTransform, Injectable, BadRequestException } from '@nestjs/common';
import * as Joi from 'joi';
@Injectable()
export class JoiPipe implements PipeTransform {
constructor(private schema: Joi.Schema) {}
transform(value: unknown) {
const { error, value: validated } = this.schema.validate(value, {
abortEarly: false,
});
if (error) {
throw new BadRequestException(error.details.map(d => d.message));
}
return validated;
}
}
Discussion