Let's Use `IsEnum` Properly in NestJS with class-validator
When validating DTOs in NestJS, there are many cases where you need to “allow only specific values”. For example, you may want to allow only 'admin' | 'user' | 'blogger' for the role (role) or 'S' | 'M' | 'L' | 'XL' for product size (size). In such cases, using the @IsEnum decorator from class-validator effectively allows for clean and secure input validation.
Review of Basic Usage of @IsEnum
// enums/user-role.enum.ts
export enum UserRole {
ADMIN = 'admin',
USER = 'user',
BLOGGER = 'blogger',
}
// dto/create-user.dto.ts
import { IsEnum, IsString, IsNotEmpty, IsEmail } from 'class-validator';
import { UserRole } from '../enums/user-role.enum';
export class CreateUserDto {
@IsString()
@IsNotEmpty()
name: string;
@IsEmail()
email: string;
@IsEnum(UserRole, {
message: 'role must be one of: admin, user, blogger',
})
role: UserRole;
}
@IsEnum ensures both type safety and data integrity. In other words, if the role value isn’t included in the enum, it will automatically throw a 400 error.
Case-insensitive Validation
In reality, users may send inputs like "Admin", "ADMIN", or "admin". Not accepting these can lead to unnecessary user errors. Here are three patterns to solve this 👇
1️⃣ Convert to Lowercase First with @Transform
The simplest and most practical approach.
import { IsEnum, IsString, IsNotEmpty } from 'class-validator';
import { Transform } from 'class-transformer';
import { UserRole } from '../enums/user-role.enum';
export class CreateUserDto {
@IsString()
@IsNotEmpty()
name: string;
@Transform(({ value }) => value?.toString().toLowerCase())
@IsEnum(UserRole, {
message: 'role must be one of: admin, user, blogger',
})
role: UserRole;
}
💡 Pros: Simple implementation, almost no runtime overhead
⚠️ Cons: Enum values must be defined in lowercase
Tip: Define enum values in lowercase and convert inputs with .toLowerCase() to avoid case sensitivity errors in REST API requests.
2️⃣ Regex-based Validation with @Matches
Useful when enum values are many or change dynamically.
import { Matches, IsString } from 'class-validator';
import { UserRole } from '../enums/user-role.enum';
export class CreateUserDto {
@IsString()
@Matches(`^(${Object.values(UserRole).join('|')})$`, 'i', {
message: 'role must be one of: admin, user, blogger (case-insensitive)',
})
role: UserRole;
}
💡 Pros: Case-insensitive with
iflag
⚠️ Cons: More complex regex, no enum type autocompletion
3️⃣ Create a Custom Decorator: IsEnumCaseInsensitive
This is a truly “production-grade” solution. Instead of repeating conversions or regex, let’s build a reusable validator.
import {
registerDecorator,
ValidationOptions,
ValidatorConstraint,
ValidatorConstraintInterface
} from 'class-validator';
@ValidatorConstraint({ async: false })
export class IsEnumCaseInsensitiveConstraint implements ValidatorConstraintInterface {
validate(value: any, args: any) {
const [enumObject] = args.constraints;
const enumValues = Object.values(enumObject).map(v => v.toString().toLowerCase());
return enumValues.includes(value?.toString().toLowerCase());
}
defaultMessage() {
return 'The value is not included in the allowed enum list.';
}
}
export function IsEnumCaseInsensitive(enumObject: any, validationOptions?: ValidationOptions) {
return function (object: Object, propertyName: string) {
registerDecorator({
target: object.constructor,
propertyName,
options: validationOptions,
constraints: [enumObject],
validator: IsEnumCaseInsensitiveConstraint,
});
};
}
Usage example 👇
import { Transform } from 'class-transformer';
import { IsEnumCaseInsensitive } from '../validators/is-enum-case-insensitive.decorator';
import { UserRole } from '../enums/user-role.enum';
export class CreateUserDto {
@Transform(({ value }) => value?.toString().toLowerCase())
@IsEnumCaseInsensitive(UserRole, {
message: 'role must be one of: admin, user, blogger (case-insensitive)',
})
role: UserRole;
}
💡 Pros: Reusable per enum
⚙️ Cons: Slight boilerplate overhead
Pro Tip: Place shared validation decorators in the src/validators/ directory and import them in all DTOs for easier maintenance.
Pro Tip: Always Enable transform: true Option
When setting up ValidationPipe in NestJS’s main.ts, make sure to enable transform: true.
app.useGlobalPipes(
new ValidationPipe({
transform: true,
whitelist: true,
forbidNonWhitelisted: true,
}),
);
This setting is required for @Transform() to work and for proper string-to-enum conversion.
Note: If `transform` is disabled, both @Transform() and @Type() in DTOs will be ignored, causing enum validation to always fail.
Other Useful Validation Patterns
In addition to @IsEnum, class-validator offers several combinations that make DTOs more robust 👇
Complex String Validation
@IsString()
@MinLength(2)
@MaxLength(30)
@Matches(/^[가-힣a-zA-Z\s]+$/, {
message: 'Name can only include Korean, English letters, and spaces.',
})
name: string;
Nested Object Validation
import { ValidateNested, Type } from 'class-validator';
class AddressDto {
@IsString()
city: string;
@IsString()
street: string;
}
export class UserWithAddressDto {
@IsString()
name: string;
@ValidateNested()
@Type(() => AddressDto)
address: AddressDto;
}
Object Validation in Arrays
import { IsArray, ValidateNested, Type } from 'class-validator';
import { ProductSize } from '../enums/product-size.enum';
class OrderItemDto {
@IsString()
productName: string;
@IsEnum(ProductSize)
size: ProductSize;
}
export class OrderDto {
@IsArray()
@ValidateNested({ each: true })
@Type(() => OrderItemDto)
items: OrderItemDto[];
}
IsEnum vs Type Safety
TypeScript’s enum guarantees compile-time type safety, while @IsEnum validates runtime data. So you need both for real safety.
| Item | TypeScript Enum | @IsEnum |
|---|---|---|
| When | Compile Time | Runtime |
| Role | Type Constraint | Actual Request Validation |
| On Failure | Compile Error | 400 Bad Request |