Data validation is an important aspect of application development. TypeORM can integrate with various validator libraries to ensure data integrity and consistency. This article details how to use validators in TypeORM for data validation.
Validator Basic Concepts
What are Validators
Validators are mechanisms for verifying that data meets specific rules, including:
- Field type validation
- Field format validation
- Field length validation
- Custom business rule validation
- Cross-field validation
Common Validator Libraries
- class-validator: Most popular TypeScript validator library
- class-transformer: For object transformation and validation
- joi: Powerful object schema validation library
- zod: TypeScript-first schema validation library
Using class-validator
Installing Dependencies
bashnpm install class-validator class-transformer npm install --save-dev @types/class-transformer
Basic Validation Example
typescriptimport { Entity, PrimaryGeneratedColumn, Column, BeforeInsert, BeforeUpdate } from 'typeorm'; import { IsEmail, IsNotEmpty, IsString, MinLength, MaxLength, IsInt, Min, Max, IsOptional, IsDateString, IsEnum, ValidateIf, ValidateNested } from 'class-validator'; import { Type } from 'class-transformer'; @Entity() export class User { @PrimaryGeneratedColumn() id: number; @Column() @IsNotEmpty({ message: 'Name cannot be empty' }) @IsString({ message: 'Name must be a string' }) @MinLength(2, { message: 'Name must be at least 2 characters' }) @MaxLength(100, { message: 'Name must not exceed 100 characters' }) name: string; @Column({ unique: true }) @IsEmail({}, { message: 'Invalid email format' }) @IsNotEmpty({ message: 'Email cannot be empty' }) email: string; @Column({ nullable: true }) @IsOptional() @MinLength(8, { message: 'Password must be at least 8 characters' }) @MaxLength(100, { message: 'Password must not exceed 100 characters' }) password?: string; @Column({ type: 'int', nullable: true }) @IsOptional() @IsInt({ message: 'Age must be an integer' }) @Min(18, { message: 'Age must be at least 18' }) @Max(120, { message: 'Age must not exceed 120' }) age?: number; @Column({ type: 'enum', enum: ['active', 'inactive', 'suspended'], default: 'active' }) @IsEnum(['active', 'inactive', 'suspended'], { message: 'Invalid status' }) status: string; @Column({ type: 'date', nullable: true }) @IsOptional() @IsDateString({}, { message: 'Invalid date format' }) birthDate?: Date; @BeforeInsert() @BeforeUpdate() async validate() { const errors = await validate(this); if (errors.length > 0) { throw new Error(`Validation failed: ${JSON.stringify(errors)}`); } } }
Advanced Validation
Custom Validators
typescriptimport { ValidatorConstraint, ValidatorConstraintInterface, registerDecorator, ValidationOptions } from 'class-validator'; // Custom validator: Check if username is unique @ValidatorConstraint({ name: 'isUsernameUnique', async: true }) export class IsUsernameUniqueConstraint implements ValidatorConstraintInterface { async validate(username: string) { // Should query database to check if username is unique // Example code const userExists = await checkUsernameExists(username); return !userExists; } defaultMessage(args: ValidationArguments) { return 'Username already exists'; } } // Custom decorator export function IsUsernameUnique(validationOptions?: ValidationOptions) { return function (object: Object, propertyName: string) { registerDecorator({ target: object.constructor, propertyName: propertyName, options: validationOptions, constraints: [], validator: IsUsernameUniqueConstraint, }); }; } // Use custom validator @Entity() export class User { @Column({ unique: true }) @IsUsernameUnique({ message: 'Username already exists' }) username: string; }
Conditional Validation
typescriptimport { ValidateIf } from 'class-validator'; @Entity() export class User { @Column() @IsNotEmpty() accountType: 'personal' | 'business'; @Column({ nullable: true }) @ValidateIf(o => o.accountType === 'business') @IsNotEmpty({ message: 'Company name is required for business accounts' }) companyName?: string; @Column({ nullable: true }) @ValidateIf(o => o.accountType === 'business') @IsNotEmpty({ message: 'Tax ID is required for business accounts' }) taxId?: string; @Column({ nullable: true }) @ValidateIf(o => o.accountType === 'personal') @IsNotEmpty({ message: 'Personal ID is required for personal accounts' }) personalId?: string; }
Nested Object Validation
typescriptimport { ValidateNested, Type } from 'class-transformer'; import { IsNotEmpty, IsString, ValidateIf } from 'class-validator'; class Address { @IsNotEmpty() @IsString() street: string; @IsNotEmpty() @IsString() city: string; @IsNotEmpty() @IsString() zipCode: string; } @Entity() export class User { @PrimaryGeneratedColumn() id: number; @Column() @IsNotEmpty() name: string; @Column({ type: 'json', nullable: true }) @ValidateIf(o => o.hasAddress) @ValidateNested() @Type(() => Address) address?: Address; @Column({ default: false }) hasAddress: boolean; }
Cross-Field Validation
typescriptimport { ValidatorConstraint, ValidatorConstraintInterface, registerDecorator, ValidationOptions, ValidationArguments } from 'class-validator'; // Custom validator: Confirm password matches @ValidatorConstraint({ name: 'isPasswordMatching', async: false }) export class IsPasswordMatchingConstraint implements ValidatorConstraintInterface { validate(password: string, args: ValidationArguments) { const object = args.object as any; return password === object.password; } defaultMessage(args: ValidationArguments) { return 'Passwords do not match'; } } export function IsPasswordMatching(validationOptions?: ValidationOptions) { return function (object: Object, propertyName: string) { registerDecorator({ target: object.constructor, propertyName: propertyName, options: validationOptions, constraints: [], validator: IsPasswordMatchingConstraint, }); }; } @Entity() export class User { @Column() @IsNotEmpty() @MinLength(8) password: string; @Column({ nullable: true }) @IsPasswordMatching({ message: 'Passwords do not match' }) confirmPassword?: string; }
Validation Error Handling
Validate and Get Errors
typescriptimport { validate, ValidationError } from 'class-validator'; async function createUser(userData: Partial<User>) { const user = new User(); Object.assign(user, userData); const errors = await validate(user); if (errors.length > 0) { // Format error messages const formattedErrors = this.formatValidationErrors(errors); throw new Error(`Validation failed: ${JSON.stringify(formattedErrors)}`); } // Save user return await userRepository.save(user); } function formatValidationErrors(errors: ValidationError[]): any { const result: any = {}; errors.forEach(error => { const constraints = error.constraints || {}; result[error.property] = Object.values(constraints).join(', '); if (error.children && error.children.length > 0) { result[error.property] = { ...result[error.property], ...this.formatValidationErrors(error.children) }; } }); return result; } // Usage example try { const user = await createUser({ name: '', email: 'invalid-email', age: 15 }); } catch (error) { console.error(error.message); // Output: Validation failed: {"name":"Name cannot be empty","email":"Invalid email format","age":"Age must be at least 18"} }
Custom Validation Middleware
typescriptimport { validate } from 'class-validator'; import { plainToClass } from 'class-transformer'; export function validationMiddleware<T extends object>( type: new () => T ) { return async (req: any, res: any, next: any) => { const dto = plainToClass(type, req.body); const errors = await validate(dto); if (errors.length > 0) { const formattedErrors = formatValidationErrors(errors); return res.status(400).json({ error: 'Validation failed', details: formattedErrors }); } req.body = dto; next(); }; } // Use in Express import express from 'express'; const app = express(); app.post('/users', validationMiddleware(User), async (req, res) => { const user = await userRepository.save(req.body); res.json(user); } );
Validator Decorators Explained
String Validation
typescript@Entity() export class User { @Column() @IsString() @IsNotEmpty() @MinLength(2) @MaxLength(100) @IsAlphanumeric() name: string; @Column() @IsEmail() @IsLowercase() email: string; @Column() @IsUrl() website?: string; @Column() @IsPhoneNumber(null) // Need to install class-validator-phone-number phone?: string; }
Number Validation
typescript@Entity() export class Product { @Column({ type: 'decimal', precision: 10, scale: 2 }) @IsNumber() @Min(0) @Max(999999.99) price: number; @Column({ type: 'int' }) @IsInt() @IsPositive() stock: number; @Column({ type: 'int' }) @IsInt() @IsDivisibleBy(10) quantity: number; }
Date Validation
typescript@Entity() export class Event { @Column({ type: 'date' }) @IsDateString() @IsBefore('endDate') startDate: Date; @Column({ type: 'date' }) @IsDateString() @IsAfter('startDate') endDate: Date; @Column({ type: 'date' }) @IsDateString() @IsFuture() registrationDeadline?: Date; }
Array and Object Validation
typescript@Entity() export class User { @Column({ type: 'simple-array' }) @IsArray() @ArrayNotEmpty() @ArrayMinSize(1) @ArrayMaxSize(10) @IsString({ each: true }) tags: string[]; @Column({ type: 'json', nullable: true }) @IsObject() @IsNotEmptyObject() metadata?: Record<string, any>; @Column({ type: 'simple-array', nullable: true }) @IsArray() @ArrayUnique() @IsEmail({ each: true }) additionalEmails?: string[]; }
Validation Best Practices
1. Layered Validation
typescript// Entity layer validation: Database-level validation @Entity() export class User { @Column() @IsNotEmpty() @IsString() name: string; @BeforeInsert() @BeforeUpdate() async validateEntity() { const errors = await validate(this); if (errors.length > 0) { throw new Error(`Entity validation failed: ${JSON.stringify(errors)}`); } } } // DTO layer validation: API request-level validation class CreateUserDto { @IsNotEmpty() @IsString() @MinLength(2) @MaxLength(100) name: string; @IsNotEmpty() @IsEmail() email: string; @IsNotEmpty() @MinLength(8) password: string; } // Use DTO validation in service layer async function createUser(dto: CreateUserDto) { const errors = await validate(dto); if (errors.length > 0) { throw new ValidationException(errors); } const user = new User(); Object.assign(user, dto); return await userRepository.save(user); }
2. Async Validation
typescript@ValidatorConstraint({ name: 'isEmailUnique', async: true }) export class IsEmailUniqueConstraint implements ValidatorConstraintInterface { async validate(email: string) { const user = await userRepository.findOne({ where: { email } }); return !user; } defaultMessage() { return 'Email already exists'; } } @Entity() export class User { @Column({ unique: true }) @IsEmailUnique() email: string; }
3. Internationalized Error Messages
typescriptimport { ValidatorConstraint, ValidatorConstraintInterface } from 'class-validator'; @ValidatorConstraint({ name: 'customValidator', async: false }) export class CustomValidatorConstraint implements ValidatorConstraintInterface { validate(value: any, args: ValidationArguments) { return true; } defaultMessage(args: ValidationArguments) { // Return different error messages based on locale const locale = args.object['locale'] || 'en'; const messages = { en: 'Custom validation failed', zh: '自定义验证失败', ja: 'カスタム検証に失敗しました' }; return messages[locale] || messages.en; } }
4. Performance Optimization
typescript// Avoid time-consuming operations in validators @ValidatorConstraint({ name: 'isUnique', async: true }) export class IsUniqueConstraint implements ValidatorConstraintInterface { private cache = new Map<string, boolean>(); async validate(value: any, args: ValidationArguments) { const cacheKey = `${args.targetName}.${args.property}.${value}`; // Check cache if (this.cache.has(cacheKey)) { return this.cache.get(cacheKey); } // Perform validation const result = await this.checkUniqueness(value, args); // Cache result this.cache.set(cacheKey, result); return result; } private async checkUniqueness(value: any, args: ValidationArguments): Promise<boolean> { // Actual uniqueness check logic return true; } }
5. Testing Validators
typescriptimport { validate } from 'class-validator'; describe('User Validation', () => { it('should validate valid user', async () => { const user = new User(); user.name = 'John Doe'; user.email = 'john@example.com'; user.age = 25; const errors = await validate(user); expect(errors.length).toBe(0); }); it('should fail validation for invalid email', async () => { const user = new User(); user.name = 'John Doe'; user.email = 'invalid-email'; user.age = 25; const errors = await validate(user); expect(errors.length).toBeGreaterThan(0); expect(errors[0].constraints).toHaveProperty('isEmail'); }); it('should fail validation for underage user', async () => { const user = new User(); user.name = 'John Doe'; user.email = 'john@example.com'; user.age = 15; const errors = await validate(user); expect(errors.length).toBeGreaterThan(0); expect(errors[0].constraints).toHaveProperty('min'); }); });
Integration with Other Libraries
Integration with Joi
typescriptimport * as Joi from 'joi'; const userSchema = Joi.object({ name: Joi.string().min(2).max(100).required(), email: Joi.string().email().required(), age: Joi.number().integer().min(18).max(120).optional(), password: Joi.string().min(8).required() }); @Entity() export class User { @BeforeInsert() @BeforeUpdate() async validateWithJoi() { const { error } = userSchema.validate(this); if (error) { throw new Error(`Validation failed: ${error.details[0].message}`); } } }
Integration with Zod
typescriptimport { z } from 'zod'; const userSchema = z.object({ name: z.string().min(2).max(100), email: z.string().email(), age: z.number().int().min(18).max(120).optional(), password: z.string().min(8) }); @Entity() export class User { @BeforeInsert() @BeforeUpdate() async validateWithZod() { const result = userSchema.safeParse(this); if (!result.success) { throw new Error(`Validation failed: ${JSON.stringify(result.error.errors)}`); } } }
TypeORM's validator functionality provides powerful data validation capabilities. Proper use of validators can ensure data integrity and consistency, improving application robustness.