Custom Validation Recipe
Add complex validation rules beyond basic Prisma constraints.
Goal
Implement advanced validation for a user registration system:
- Strong password requirements
- Email domain whitelist
- Username format validation
- Phone number formats
- Custom business rules
Basic Validation from Prisma
Start with Prisma schema validation:
// @tg_form()
model User {
id String @id @default(uuid())
username String @unique // @min(3) @max(20)
/// @tg_format(email)
email String @unique
password String // @min(8)
firstName String // @min(2) @max(50)
lastName String // @min(2) @max(50)
/// @tg_format(tel)
phone String?
age Int // @min(18)
website String? // @pattern(/^https?:\/\//)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
Generate:
tgraph dtos
This creates:
export class CreateUserTgDto {
@IsString()
@MinLength(3)
@MaxLength(20)
@IsNotEmpty()
username: string;
@IsEmail()
@IsNotEmpty()
email: string;
@IsString()
@MinLength(8)
@IsNotEmpty()
password: string;
@IsString()
@MinLength(2)
@MaxLength(50)
@IsNotEmpty()
firstName: string;
@IsString()
@MinLength(2)
@MaxLength(50)
@IsNotEmpty()
lastName: string;
@Matches(/^[0-9+()\s-]+$/)
@IsOptional()
phone?: string;
@IsInt()
@Min(18)
@IsNotEmpty()
age: number;
@IsString()
@Matches(/^https?:\/\//)
@IsOptional()
website?: string;
}
Advanced Validation Patterns
Pattern 1: Custom DTO with Complex Rules
Create a custom registration DTO with stronger validation:
// user-registration.dto.ts
import {
IsString,
IsEmail,
MinLength,
MaxLength,
Matches,
IsInt,
Min,
Max,
IsOptional,
Validate,
ValidateIf,
IsNotEmpty,
} from 'class-validator';
import { Transform } from 'class-transformer';
export class UserRegistrationDto {
@IsString()
@MinLength(3)
@MaxLength(20)
@Matches(/^[a-zA-Z0-9_-]+$/, {
message: 'Username can only contain letters, numbers, underscores, and hyphens',
})
@Transform(({ value }) => value?.trim().toLowerCase())
username: string;
@IsEmail()
@Matches(/^[a-zA-Z0-9._%+-]+@(company\.com|partner\.org)$/, {
message: 'Email must be from company.com or partner.org domain',
})
@Transform(({ value }) => value?.trim().toLowerCase())
email: string;
@IsString()
@MinLength(8)
@MaxLength(100)
@Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/, {
message: 'Password must contain uppercase, lowercase, number, and special character',
})
password: string;
@IsString()
@MinLength(2)
@MaxLength(50)
@Matches(/^[a-zA-Z\s'-]+$/, {
message: 'First name can only contain letters, spaces, hyphens, and apostrophes',
})
@Transform(({ value }) => value?.trim())
firstName: string;
@IsString()
@MinLength(2)
@MaxLength(50)
@Matches(/^[a-zA-Z\s'-]+$/, {
message: 'Last name can only contain letters, spaces, hyphens, and apostrophes',
})
@Transform(({ value }) => value?.trim())
lastName: string;
@IsString()
@IsOptional()
@Matches(/^\+?[1-9]\d{1,14}$/, {
message: 'Phone must be a valid international format',
})
phone?: string;
@IsInt()
@Min(18, { message: 'Must be at least 18 years old' })
@Max(120, { message: 'Age must be realistic' })
age: number;
@IsString()
@IsOptional()
@Matches(/^https:\/\/[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/, {
message: 'Website must be a valid HTTPS URL',
})
website?: string;
@IsString()
@IsOptional()
@MaxLength(500)
bio?: string;
}
Pattern 2: Custom Validators
Create reusable custom validators:
// validators/is-strong-password.validator.ts
import { ValidatorConstraint, ValidatorConstraintInterface, ValidationArguments } from 'class-validator';
@ValidatorConstraint({ name: 'isStrongPassword', async: false })
export class IsStrongPasswordConstraint implements ValidatorConstraintInterface {
validate(password: string, args: ValidationArguments): boolean {
if (!password) return false;
const hasUpperCase = /[A-Z]/.test(password);
const hasLowerCase = /[a-z]/.test(password);
const hasNumbers = /\d/.test(password);
const hasSpecialChar = /[@$!%*?&]/.test(password);
const isLongEnough = password.length >= 8;
return hasUpperCase && hasLowerCase && hasNumbers && hasSpecialChar && isLongEnough;
}
defaultMessage(args: ValidationArguments): string {
return 'Password must be at least 8 characters and contain uppercase, lowercase, number, and special character (@$!%*?&)';
}
}
// Usage
export class UserRegistrationDto {
@Validate(IsStrongPasswordConstraint)
password: string;
}
Pattern 3: Async Validators
Validate against database:
// validators/is-unique-email.validator.ts
import { ValidatorConstraint, ValidatorConstraintInterface, ValidationArguments } from 'class-validator';
import { Injectable } from '@nestjs/common';
import { PrismaService } from '@/infrastructure/database/prisma.service';
@ValidatorConstraint({ name: 'isUniqueEmail', async: true })
@Injectable()
export class IsUniqueEmailConstraint implements ValidatorConstraintInterface {
constructor(private readonly prisma: PrismaService) {}
async validate(email: string, args: ValidationArguments): Promise<boolean> {
if (!email) return false;
const user = await this.prisma.user.findUnique({
where: { email: email.toLowerCase() },
});
return !user;
}
defaultMessage(args: ValidationArguments): string {
return 'Email $value is already taken';
}
}
// Usage
export class UserRegistrationDto {
@IsEmail()
@Validate(IsUniqueEmailConstraint)
email: string;
}
// Register in module
@Module({
providers: [IsUniqueEmailConstraint],
})
export class UserModule {}
Pattern 4: Cross-Field Validation
Validate related fields:
// validators/is-password-match.validator.ts
import { ValidatorConstraint, ValidatorConstraintInterface, ValidationArguments } from 'class-validator';
@ValidatorConstraint({ name: 'isPasswordMatch', async: false })
export class IsPasswordMatchConstraint implements ValidatorConstraintInterface {
validate(confirmPassword: string, args: ValidationArguments): boolean {
const object = args.object as any;
return confirmPassword === object.password;
}
defaultMessage(args: ValidationArguments): string {
return 'Passwords do not match';
}
}
// Usage
export class UserRegistrationDto {
@IsString()
@MinLength(8)
password: string;
@Validate(IsPasswordMatchConstraint)
confirmPassword: string;
}
Pattern 5: Conditional Validation
Validate based on other field values:
export class UpdateUserDto {
@IsString()
@IsOptional()
username?: string;
@IsEmail()
@IsOptional()
email?: string;
// Only validate if password is being changed
@IsString()
@IsOptional()
@MinLength(8)
newPassword?: string;
// Required only if newPassword is provided
@ValidateIf((o) => o.newPassword !== undefined)
@IsString()
@IsNotEmpty({ message: 'Current password is required when changing password' })
currentPassword?: string;
// Must match newPassword if provided
@ValidateIf((o) => o.newPassword !== undefined)
@Validate(IsPasswordMatchConstraint)
confirmNewPassword?: string;
}
Service-Level Validation
Implement business rule validation in services:
// user.service.ts
import { UserTgService } from './user.tg.service';
import { BadRequestException, ConflictException, UnauthorizedException } from '@nestjs/common';
@Injectable()
export class UserService extends UserTgService {
async register(dto: UserRegistrationDto): Promise<User> {
// Business rule: Check if username is blacklisted
await this.validateUsername(dto.username);
// Business rule: Check email domain
await this.validateEmailDomain(dto.email);
// Business rule: Check if user is old enough
this.validateAge(dto.age);
// Hash password
const hashedPassword = await bcrypt.hash(dto.password, 10);
// Create user
return this.prisma.user.create({
data: {
...dto,
password: hashedPassword,
},
});
}
private async validateUsername(username: string): Promise<void> {
const blacklist = ['admin', 'root', 'system', 'test'];
if (blacklist.includes(username.toLowerCase())) {
throw new BadRequestException('Username is not allowed');
}
// Check if taken
const existing = await this.prisma.user.findUnique({
where: { username },
});
if (existing) {
throw new ConflictException('Username is already taken');
}
}
private async validateEmailDomain(email: string): Promise<void> {
const allowedDomains = ['company.com', 'partner.org'];
const domain = email.split('@')[1];
if (!allowedDomains.includes(domain)) {
throw new BadRequestException(`Email must be from one of: ${allowedDomains.join(', ')}`);
}
// Check if taken
const existing = await this.prisma.user.findUnique({
where: { email },
});
if (existing) {
throw new ConflictException('Email is already registered');
}
}
private validateAge(age: number): void {
const currentYear = new Date().getFullYear();
const birthYear = currentYear - age;
if (birthYear < 1900) {
throw new BadRequestException('Invalid age');
}
if (age < 18) {
throw new BadRequestException('You must be at least 18 years old');
}
if (age > 120) {
throw new BadRequestException('Invalid age');
}
}
async updatePassword(userId: string, currentPassword: string, newPassword: string): Promise<void> {
const user = await this.findOne(userId);
// Verify current password
const isValid = await bcrypt.compare(currentPassword, user.password);
if (!isValid) {
throw new UnauthorizedException('Current password is incorrect');
}
// Ensure new password is different
const isSame = await bcrypt.compare(newPassword, user.password);
if (isSame) {
throw new BadRequestException('New password must be different from current password');
}
// Hash and update
const hashedPassword = await bcrypt.hash(newPassword, 10);
await this.prisma.user.update({
where: { id: userId },
data: { password: hashedPassword },
});
}
}
Frontend Validation
Add client-side validation in React Admin:
// UserCreate.tsx
import { useNotify } from 'react-admin';
const validateUsername = (value: string) => {
if (!value) return 'Required';
if (value.length < 3) return 'Must be at least 3 characters';
if (value.length > 20) return 'Must be at most 20 characters';
if (!/^[a-zA-Z0-9_-]+$/.test(value)) {
return 'Can only contain letters, numbers, underscores, and hyphens';
}
return undefined;
};
const validateEmail = (value: string) => {
if (!value) return 'Required';
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
return 'Invalid email format';
}
if (!/(company\.com|partner\.org)$/.test(value)) {
return 'Must be from company.com or partner.org';
}
return undefined;
};
const validatePassword = (value: string) => {
if (!value) return 'Required';
if (value.length < 8) return 'Must be at least 8 characters';
if (!/(?=.*[a-z])/.test(value)) return 'Must contain lowercase letter';
if (!/(?=.*[A-Z])/.test(value)) return 'Must contain uppercase letter';
if (!/(?=.*\d)/.test(value)) return 'Must contain number';
if (!/(?=.*[@$!%*?&])/.test(value)) return 'Must contain special character';
return undefined;
};
const validateAge = (value: number) => {
if (!value) return 'Required';
if (value < 18) return 'Must be at least 18';
if (value > 120) return 'Must be realistic';
return undefined;
};
export const UserCreate = () => (
<Create>
<SimpleForm>
<TextInput source="username" validate={validateUsername} required />
<TextInput source="email" type="email" validate={validateEmail} required />
<TextInput source="password" type="password" validate={validatePassword} required />
<TextInput source="firstName" required />
<TextInput source="lastName" required />
<TextInput source="phone" />
<NumberInput source="age" validate={validateAge} required />
<TextInput source="website" />
</SimpleForm>
</Create>
);
Testing Validation
Write tests for your validation:
// user.service.spec.ts
describe('UserService', () => {
let service: UserService;
let prisma: PrismaService;
beforeEach(async () => {
const module = await Test.createTestingModule({
providers: [UserService, PrismaService],
}).compile();
service = module.get<UserService>(UserService);
prisma = module.get<PrismaService>(PrismaService);
});
describe('register', () => {
it('should reject blacklisted usernames', async () => {
const dto = {
username: 'admin',
email: 'test@company.com',
password: 'Test123!@',
firstName: 'John',
lastName: 'Doe',
age: 25,
};
await expect(service.register(dto)).rejects.toThrow('Username is not allowed');
});
it('should reject invalid email domains', async () => {
const dto = {
username: 'testuser',
email: 'test@invalid.com',
password: 'Test123!@',
firstName: 'John',
lastName: 'Doe',
age: 25,
};
await expect(service.register(dto)).rejects.toThrow('Email must be from one of');
});
it('should reject underage users', async () => {
const dto = {
username: 'testuser',
email: 'test@company.com',
password: 'Test123!@',
firstName: 'John',
lastName: 'Doe',
age: 17,
};
await expect(service.register(dto)).rejects.toThrow('You must be at least 18 years old');
});
it('should successfully register valid user', async () => {
const dto = {
username: 'testuser',
email: 'test@company.com',
password: 'Test123!@',
firstName: 'John',
lastName: 'Doe',
age: 25,
};
jest.spyOn(prisma.user, 'findUnique').mockResolvedValue(null);
jest.spyOn(prisma.user, 'create').mockResolvedValue(dto as any);
const result = await service.register(dto);
expect(result).toBeDefined();
});
});
});
Best Practices
1. Layer Your Validation
- DTO Level – Format and basic constraints
- Service Level – Business rules and database checks
- Frontend – User experience and immediate feedback
2. Provide Clear Error Messages
@Matches(/^[a-zA-Z0-9_-]+$/, {
message: 'Username can only contain letters, numbers, underscores, and hyphens',
})
username: string;
3. Use Transformers for Normalization
@Transform(({ value }) => value?.trim().toLowerCase())
email: string;
4. Create Reusable Validators
Don’t repeat validation logic—create custom validators.
5. Test Edge Cases
Test validation with:
- Empty values
- Boundary values
- Invalid formats
- Null/undefined
- Special characters
Next Steps
- Extending Generated Code – Advanced patterns
- Basic CRUD – Start simpler
- Field Directives – Built-in validation