| name | validation |
| description | Schema validation patterns for Zod and class-validator in NestJS. Detect which library the project uses and follow its patterns. Use for DTO validation, request/response validation, custom validators, and TypeScript type inference. |
Validation Skill
Load this skill before implementing input validation, DTOs, or schema definitions.
Library Detection (CRITICAL)
ALWAYS detect and use the project's existing validation library. NEVER mix validation libraries in the same project.
| Detection Method | Library |
|---|
zod or nestjs-zod in package.json | Zod |
class-validator + class-transformer in package.json | class-validator |
grep -E "\"zod\"|\"nestjs-zod\"" package.json
grep -E "\"class-validator\"" package.json
If no validation library exists yet:
- For new NestJS projects: Recommend
class-validator (native NestJS support)
- For schema-first or non-NestJS: Recommend
zod (better type inference)
- If using NestJS with Zod: Use
nestjs-zod for seamless integration
Quick Comparison
| Feature | Zod | class-validator |
|---|
| Approach | Schema-first, functional | Decorator-based, class-first |
| Type Inference | Excellent (z.infer<typeof schema>) | Requires separate interface |
| NestJS Integration | Via nestjs-zod | Native ValidationPipe |
| Bundle Size | ~12kb | ~40kb (with class-transformer) |
| Custom Validation | .refine(), .superRefine() | @Validate(), registerDecorator |
| Composability | Excellent (schemas are values) | Limited (class inheritance) |
| Error Messages | Customizable per field | Customizable per decorator |
| Async Validation | .refineAsync() | Built-in async support |
When to Choose
| Use Case | Recommended |
|---|
| Standard NestJS API | class-validator |
| Schema-first design | Zod |
| Heavy type inference needs | Zod |
| Existing NestJS codebase | Match existing |
| OpenAPI/Swagger integration | class-validator (better decorators) |
| Shared frontend/backend schemas | Zod |
Zod Patterns
Installation
bun add zod
bun add zod nestjs-zod
Schema Definitions
import { z } from 'zod';
const stringSchema = z.string();
const numberSchema = z.number();
const booleanSchema = z.boolean();
const dateSchema = z.date();
const emailSchema = z.string().email();
const urlSchema = z.string().url();
const uuidSchema = z.string().uuid();
const minMaxSchema = z.string().min(3).max(100);
const regexSchema = z.string().regex(/^[A-Z]/);
const positiveSchema = z.number().positive();
const intSchema = z.number().int();
const rangeSchema = z.number().min(0).max(100);
const statusSchema = z.enum(['pending', 'active', 'completed']);
enum Status { Pending, Active, Completed }
const nativeEnumSchema = z.nativeEnum(Status);
const tagsSchema = z.array(z.string()).min(1).max(10);
const uniqueSchema = z.array(z.string()).refine(
(items) => new Set(items).size === items.length,
{ message: 'Array must contain unique items' }
);
const userSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
name: z.string().min(1).max(100),
age: z.number().int().positive().optional(),
role: z.enum(['user', 'admin']).default('user'),
tags: z.array(z.string()).default([]),
metadata: z.record(z.string(), z.unknown()).optional(),
});
type User = z.infer<typeof userSchema>;
Parse vs SafeParse
try {
const user = userSchema.parse(unknownData);
} catch (error) {
if (error instanceof z.ZodError) {
console.error(error.errors);
}
}
const result = userSchema.safeParse(unknownData);
if (result.success) {
const user = result.data;
} else {
const errors = result.error.errors;
}
const asyncResult = await asyncSchema.safeParseAsync(data);
Refinements and Transforms
const passwordSchema = z.string()
.min(8)
.refine(
(val) => /[A-Z]/.test(val),
{ message: 'Must contain uppercase letter' }
)
.refine(
(val) => /[0-9]/.test(val),
{ message: 'Must contain number' }
);
const confirmPasswordSchema = z.object({
password: z.string().min(8),
confirmPassword: z.string(),
}).superRefine((data, ctx) => {
if (data.password !== data.confirmPassword) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Passwords must match',
path: ['confirmPassword'],
});
}
});
const lowercaseEmailSchema = z.string()
.email()
.transform((val) => val.toLowerCase());
const dateStringSchema = z.string()
.transform((val) => new Date(val))
.refine((date) => !isNaN(date.getTime()), { message: 'Invalid date' });
const stringToNumber = z.string()
.transform((val) => parseInt(val, 10))
.pipe(z.number().positive());
Schema Composition
const baseUserSchema = z.object({
email: z.string().email(),
name: z.string(),
});
const createUserSchema = baseUserSchema.extend({
password: z.string().min(8),
});
const updateUserSchema = baseUserSchema.partial();
const userResponseSchema = userSchema.omit({ password: true });
const userSummarySchema = userSchema.pick({ id: true, name: true });
const withTimestamps = z.object({
createdAt: z.date(),
updatedAt: z.date(),
});
const fullUserSchema = userSchema.merge(withTimestamps);
const idSchema = z.union([z.string().uuid(), z.number().int().positive()]);
const idShortSchema = z.string().uuid().or(z.number().int().positive());
const eventSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('click'), x: z.number(), y: z.number() }),
z.object({ type: z.literal('scroll'), delta: z.number() }),
z.object({ type: z.literal('keypress'), key: z.string() }),
]);
NestJS Integration with nestjs-zod
import { ZodValidationPipe } from 'nestjs-zod';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(new ZodValidationPipe());
await app.listen(3000);
}
import { createZodDto } from 'nestjs-zod';
import { z } from 'zod';
const CreateUserSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
name: z.string().min(1).max(100),
});
export class CreateUserDto extends createZodDto(CreateUserSchema) {}
@Controller('users')
export class UserController {
@Post()
async create(@Body() dto: CreateUserDto) {
return this.userService.create(dto);
}
}
Error Formatting
function formatZodError(error: z.ZodError): Record<string, string[]> {
const formatted: Record<string, string[]> = {};
for (const issue of error.errors) {
const path = issue.path.join('.') || '_root';
if (!formatted[path]) {
formatted[path] = [];
}
formatted[path].push(issue.message);
}
return formatted;
}
const result = schema.safeParse(data);
if (!result.success) {
const errors = formatZodError(result.error);
}
const flattened = result.error.flatten();
class-validator Patterns
Installation
bun add class-validator class-transformer
Common Decorators
import {
IsString,
IsEmail,
IsNumber,
IsInt,
IsPositive,
IsBoolean,
IsDate,
IsEnum,
IsArray,
IsOptional,
IsNotEmpty,
IsUUID,
IsUrl,
Min,
Max,
MinLength,
MaxLength,
Matches,
ArrayMinSize,
ArrayMaxSize,
ValidateNested,
} from 'class-validator';
import { Type, Transform } from 'class-transformer';
export class CreateUserDto {
@IsEmail()
@Transform(({ value }) => value?.toLowerCase())
email: string;
@IsString()
@MinLength(8)
@MaxLength(100)
@Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/, {
message: 'Password must contain uppercase, lowercase, and number',
})
password: string;
@IsString()
@IsNotEmpty()
@MaxLength(100)
name: string;
@IsOptional()
@IsInt()
@IsPositive()
@Max(150)
age?: number;
@IsOptional()
@IsEnum(UserRole)
role?: UserRole;
@IsOptional()
@IsArray()
@IsString({ each: true })
@ArrayMinSize(0)
@ArrayMaxSize(10)
tags?: string[];
}
enum UserRole {
User = 'user',
Admin = 'admin',
}
Nested Validation
export class AddressDto {
@IsString()
@IsNotEmpty()
street: string;
@IsString()
@IsNotEmpty()
city: string;
@IsString()
@MinLength(2)
@MaxLength(2)
country: string;
@IsOptional()
@IsString()
postalCode?: string;
}
export class CreateUserWithAddressDto {
@IsEmail()
email: string;
@IsString()
name: string;
@ValidateNested()
@Type(() => AddressDto)
address: AddressDto;
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => AddressDto)
additionalAddresses?: AddressDto[];
}
Custom Validators
import {
registerDecorator,
ValidationOptions,
ValidationArguments,
ValidatorConstraint,
ValidatorConstraintInterface,
} from 'class-validator';
export function IsStrongPassword(validationOptions?: ValidationOptions) {
return function (object: object, propertyName: string) {
registerDecorator({
name: 'isStrongPassword',
target: object.constructor,
propertyName: propertyName,
options: validationOptions,
validator: {
validate(value: unknown) {
if (typeof value !== 'string') return false;
return (
value.length >= 8 &&
/[A-Z]/.test(value) &&
/[a-z]/.test(value) &&
/[0-9]/.test(value)
);
},
defaultMessage() {
return 'Password must be 8+ chars with uppercase, lowercase, and number';
},
},
});
};
}
export class RegisterDto {
@IsStrongPassword()
password: string;
}
@ValidatorConstraint({ name: 'isEmailUnique', async: true })
export class IsEmailUniqueConstraint implements ValidatorConstraintInterface {
constructor(private readonly userService: UserService) {}
async validate(email: string): Promise<boolean> {
const user = await this.userService.findByEmail(email);
return !user;
}
defaultMessage(): string {
return 'Email already exists';
}
}
export function IsEmailUnique(validationOptions?: ValidationOptions) {
return function (object: object, propertyName: string) {
registerDecorator({
target: object.constructor,
propertyName: propertyName,
options: validationOptions,
constraints: [],
validator: IsEmailUniqueConstraint,
});
};
}
export function Match(property: string, validationOptions?: ValidationOptions) {
return function (object: object, propertyName: string) {
registerDecorator({
name: 'match',
target: object.constructor,
propertyName: propertyName,
constraints: [property],
options: validationOptions,
validator: {
validate(value: unknown, args: ValidationArguments) {
const [relatedPropertyName] = args.constraints;
const relatedValue = (args.object as Record<string, unknown>)[relatedPropertyName];
return value === relatedValue;
},
defaultMessage(args: ValidationArguments) {
const [relatedPropertyName] = args.constraints;
return `${args.property} must match ${relatedPropertyName}`;
},
},
});
};
}
export class ChangePasswordDto {
@IsString()
@MinLength(8)
password: string;
@IsString()
@Match('password')
confirmPassword: string;
}
Validation Groups
export class UpdateUserDto {
@IsEmail({ groups: ['create', 'update'] })
email: string;
@IsString()
@MinLength(8)
@IsNotEmpty({ groups: ['create'] })
@IsOptional({ groups: ['update'] })
password?: string;
@IsString()
name: string;
}
@Post()
async create(@Body(new ValidationPipe({ groups: ['create'] })) dto: UpdateUserDto) {}
@Patch(':id')
async update(@Body(new ValidationPipe({ groups: ['update'] })) dto: UpdateUserDto) {}
Conditional Validation
import { ValidateIf } from 'class-validator';
export class PaymentDto {
@IsEnum(PaymentMethod)
method: PaymentMethod;
@ValidateIf((o) => o.method === PaymentMethod.Card)
@IsString()
@Matches(/^\d{16}$/)
cardNumber?: string;
@ValidateIf((o) => o.method === PaymentMethod.Card)
@IsString()
@Matches(/^\d{3,4}$/)
cvv?: string;
@ValidateIf((o) => o.method === PaymentMethod.Bank)
@IsString()
@IsNotEmpty()
accountNumber?: string;
}
enum PaymentMethod {
Card = 'card',
Bank = 'bank',
Crypto = 'crypto',
}
NestJS ValidationPipe Configuration
import { ValidationPipe } from '@nestjs/common';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
transformOptions: {
enableImplicitConversion: true,
},
exceptionFactory: (errors) => {
const messages = errors.map((error) => ({
field: error.property,
errors: Object.values(error.constraints || {}),
}));
return new BadRequestException({ message: 'Validation failed', errors: messages });
},
}));
await app.listen(3000);
}
@Module({
providers: [
{
provide: APP_PIPE,
useValue: new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
},
],
})
export class AppModule {}
Whitelist Mode (CRITICAL for Security)
export class CreateUserDto {
@IsEmail()
email: string;
@IsString()
name: string;
}
NestJS Integration Summary
class-validator (Native)
app.useGlobalPipes(new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}));
export class CreateUserDto {
@IsEmail()
email: string;
}
@Post()
create(@Body() dto: CreateUserDto) {}
Zod (via nestjs-zod)
import { ZodValidationPipe } from 'nestjs-zod';
app.useGlobalPipes(new ZodValidationPipe());
import { createZodDto } from 'nestjs-zod';
const schema = z.object({ email: z.string().email() });
export class CreateUserDto extends createZodDto(schema) {}
@Post()
create(@Body() dto: CreateUserDto) {}
Error Response Format (Both)
{
"statusCode": 400,
"message": "Validation failed",
"errors": [
{
"field": "email",
"errors": ["email must be a valid email address"]
},
{
"field": "password",
"errors": ["password must be at least 8 characters"]
}
]
}
Best Practices
1. Define Once, Infer Types
const userSchema = z.object({ email: z.string().email() });
type User = z.infer<typeof userSchema>;
export class UserDto {
@IsEmail()
email: string;
}
2. Always Validate at API Boundaries
@Post()
create(@Body() dto: CreateUserDto) {}
@Get()
findAll(@Query() query: PaginationDto) {}
@Get(':id')
findOne(@Param('id', ParseUUIDPipe) id: string) {}
3. Use SafeParse for User Input (Zod)
const result = schema.safeParse(userInput);
if (!result.success) {
return { errors: formatZodError(result.error) };
}
const data = schema.parse(userInput);
4. Whitelist Mode in NestJS (class-validator)
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
});
5. Compose Schemas/DTOs for Reuse
const baseSchema = z.object({ id: z.string() });
const createSchema = baseSchema.omit({ id: true });
const updateSchema = baseSchema.partial();
export class UpdateUserDto extends PartialType(CreateUserDto) {}
export class UserResponseDto extends OmitType(UserDto, ['password']) {}
Quick Checklist
Before shipping validation code:
When to Use This Skill
Load this skill when:
- Implementing input validation for APIs
- Creating DTOs for NestJS endpoints
- Building schema definitions for data parsing
- Adding custom validation rules
- Setting up global validation pipes
- Debugging validation errors
- Choosing between Zod and class-validator