NestJS + Prisma: Custom "Unique" Validation

Ensuring data uniqueness is crucial for maintaining database integrity. While Prisma handles unique constraints at the database level, validating uniqueness at the API level provides better user experience with clear, immediate feedback.
In this comprehensive guide, I'll show you how to create a custom validation decorator for NestJS that checks for unique values in your Prisma database before attempting to save records.
Why API-Level Validation?
While database constraints prevent duplicate data from being saved, they throw generic errors that aren't user-friendly. API-level validation provides immediate, clear feedback before database operations occur.
โ With API Validation
- Clear, field-specific error messages
- Faster feedback to users
- Prevents unnecessary database operations
โ Database-Only Constraints
- Generic error messages
- Errors only after database operation
- Harder to handle in frontend
The @IsUnique() Decorator
We'll create a decorator called @IsUnique() that integrates seamlessly with NestJS's validation pipeline to check if a value already exists in the specified table and field.
Core Features:
๐กUse Cases
This validation pattern is essential for any field that requires uniqueness across your application.
User Management
Email addresses, usernames, phone numbers
Content Management
Article slugs, category names, tag names
Business Logic
Product SKUs, order numbers, invoice IDs
๐Implementation Guide
Let's build a custom @IsUnique() decorator that integrates with NestJS and Prisma to provide robust uniqueness validation.
Step 1: Create the Unique Validator
// validators/is-unique.validator.ts
import {
registerDecorator,
ValidationOptions,
ValidatorConstraint,
ValidatorConstraintInterface,
ValidationArguments,
} from 'class-validator';
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
export interface IsUniqueInterface {
tableName: string;
column: string;
}
@ValidatorConstraint({ name: 'IsUniqueConstraint', async: true })
@Injectable()
export class IsUniqueConstraint implements ValidatorConstraintInterface {
constructor(private readonly prisma: PrismaService) {}
async validate(value: any, args?: ValidationArguments): Promise<boolean> {
const { tableName, column }: IsUniqueInterface = args.constraints[0];
const dataExist = await this.prisma[tableName].findFirst({
where: {
[column]: value,
},
});
return !dataExist;
}
defaultMessage(validationArguments?: ValidationArguments): string {
const field: string = validationArguments.property;
return `${field} already exist`;
}
}Step 2: Create the Decorator
// Add this to the same file: validators/is-unique.validator.ts
export function IsUnique(
options: IsUniqueInterface,
validationOptions?: ValidationOptions,
) {
return function (object: any, propertyName: string) {
registerDecorator({
name: 'IsUnique',
target: object.constructor,
propertyName: propertyName,
options: validationOptions,
constraints: [options],
validator: IsUniqueConstraint,
});
};
}Step 3: Register the Validator
// app.module.ts
import { Module } from '@nestjs/common';
import { useContainer } from 'class-validator';
import { IsUniqueConstraint } from './validators/is-unique.validator';
import { PrismaModule } from './prisma/prisma.module';
@Module({
imports: [PrismaModule],
providers: [IsUniqueConstraint],
// ... other providers
})
export class AppModule {
constructor() {
useContainer(this, { fallbackOnErrors: true });
}
}Step 4: Use in DTOs
// user/dto/create-user.dto.ts
import { IsEmail, IsNotEmpty } from 'class-validator';
import { IsUnique } from '../../validators/is-unique.validator';
export class CreateUserDto {
@IsEmail()
@IsNotEmpty()
@IsUnique(
{ tableName: 'user', column: 'email' },
{ message: 'Email $value already exists. Choose another email.' }
)
email: string;
@IsNotEmpty()
@IsUnique(
{ tableName: 'user', column: 'username' },
{ message: 'Username $value is already taken.' }
)
username: string;
@IsNotEmpty()
password: string;
}Step 5: Handle Updates (Advanced)
For update operations, you need to exclude the current record from uniqueness checks:
// Enhanced validator for updates
@ValidatorConstraint({ name: 'IsUniqueConstraint', async: true })
@Injectable()
export class IsUniqueConstraint implements ValidatorConstraintInterface {
constructor(private readonly prisma: PrismaService) {}
async validate(value: any, args?: ValidationArguments): Promise<boolean> {
const { tableName, column }: IsUniqueInterface = args.constraints[0];
// Get the current object being validated
const currentObject = args.object as any;
const excludeId = currentObject.id; // Assume we have an 'id' field
const whereClause: any = { [column]: value };
// If we have an ID, exclude the current record
if (excludeId) {
whereClause.id = { not: excludeId };
}
const dataExist = await this.prisma[tableName].findFirst({
where: whereClause,
});
return !dataExist;
}
defaultMessage(validationArguments?: ValidationArguments): string {
const field: string = validationArguments.property;
return `${field} already exists`;
}
}Usage Example
Now your API will automatically validate uniqueness before database operations:
// POST /users with duplicate email
{
"message": "Validation failed",
"errors": {
"email": ["Email john@example.com already exists. Choose another email."],
"username": ["Username johndoe is already taken."]
}
}