feat: add decorator to check if two properties match
First of all, thanks for the awesome validation solution! I use class-validator in my NestJS setup. Now I want to know if and how it is possible to check if two values match with eachother. Let's say I have a dto setup like this:
export class UserCreateDto {
@IsString()
@IsNotEmpty()
firstName: string;
@IsEmail()
emailAddress: string;
@DoesMatch(o => o.emailAddress === o.emailAddressConfirm) // <---- check if email's match
@IsEmail()
emailAddressConfirm: string;
}
@vlapo
@vlapo how can we achieve something like this??
We do not have validator for this. Feel free open PR.
Hi guys, any update @vlapo?
Is there a way to make custom validator get access to other properties
Hi! I am posting there the solution I found to implement this control. It refers to my question/answer on StackOverflow. I am going to provide a suitable solution for this particular issue.
user-create.dto.ts
export class UserCreateDto {
@IsString()
@IsNotEmpty()
firstName: string;
@IsEmail()
emailAddress: string;
@Match('emailAddress')
@IsEmail()
emailAddressConfirm: string;
}
match.decorator.ts
import {registerDecorator, ValidationArguments, ValidationOptions, ValidatorConstraint, ValidatorConstraintInterface} from 'class-validator';
export function Match(property: string, validationOptions?: ValidationOptions) {
return (object: any, propertyName: string) => {
registerDecorator({
target: object.constructor,
propertyName,
options: validationOptions,
constraints: [property],
validator: MatchConstraint,
});
};
}
@ValidatorConstraint({name: 'Match'})
export class MatchConstraint implements ValidatorConstraintInterface {
validate(value: any, args: ValidationArguments) {
const [relatedPropertyName] = args.constraints;
const relatedValue = (args.object as any)[relatedPropertyName];
return value === relatedValue;
}
}
I managed to solve a similar problem on my personal project. Hope it helps!
Added a custom message to your solution like this:
@ValidatorConstraint({name: 'Match'})
export class MatchConstraint implements ValidatorConstraintInterface {
validate(value: any, args: ValidationArguments) {
const [relatedPropertyName] = args.constraints;
const relatedValue = (args.object as any)[relatedPropertyName];
return value === relatedValue;
}
defaultMessage(args: ValidationArguments) {
const [relatedPropertyName] = args.constraints;
return `${relatedPropertyName} and ${args.property} don't match`;
}
}
force the property to exist on the class:
export function Match<K extends string, T extends { [$K in K]: any }>(
property: K,
validationOptions?: ValidationOptions,
) {
return (object: T, propertyName: string) => {
registerDecorator({
target: object.constructor,
propertyName,
options: validationOptions,
constraints: [property],
validator: MatchConstraint,
});
};
}
@mrpharderwijk and @pieromacaluso, it's works for me. Thanks!!
Full code:
import {registerDecorator, ValidationArguments, ValidationOptions, ValidatorConstraint, ValidatorConstraintInterface} from 'class-validator';
export function Match(property: string, validationOptions?: ValidationOptions) {
return (object: any, propertyName: string) => {
registerDecorator({
target: object.constructor,
propertyName,
options: validationOptions,
constraints: [property],
validator: MatchConstraint,
});
};
}
@ValidatorConstraint({name: 'Match'})
export class MatchConstraint implements ValidatorConstraintInterface {
validate(value: any, args: ValidationArguments) {
const [relatedPropertyName] = args.constraints;
const relatedValue = (args.object as any)[relatedPropertyName];
return value === relatedValue;
}
defaultMessage(args: ValidationArguments) {
const [relatedPropertyName] = args.constraints;
return `${relatedPropertyName} and ${args.property} don't match`;
}
}
Thanks everyone for a good solution, but there's a problem with type linting.
We could make a spelling mistake like:
@Match('passwordd')
// 👆
So I would like to make it more strict
import { ClassConstructor } from "class-transformer";
export const Match = <T>(
type: ClassConstructor<T>,
property: (o: T) => any,
validationOptions?: ValidationOptions,
) => {
return (object: any, propertyName: string) => {
registerDecorator({
target: object.constructor,
propertyName,
options: validationOptions,
constraints: [property],
validator: MatchConstraint,
});
};
};
@ValidatorConstraint({ name: "Match" })
export class MatchConstraint implements ValidatorConstraintInterface {
validate(value: any, args: ValidationArguments) {
const [fn] = args.constraints;
return fn(args.object) === value;
}
defaultMessage(args: ValidationArguments) {
const [constraintProperty]: (() => any)[] = args.constraints;
return `${constraintProperty} and ${args.property} does not match`;
}
}
Usage:
@Match(SignUpDto, (s) => s.password)
passwordConfirm: string;
Thanks everyone for a good solution, but there's a problem with type linting.
We could make a spelling mistake like:
@Match('passwordd') // 👆So I would like to make it more strict
export const Match = <T>( type: ClassConstructor<T>, property: (o: T) => any, validationOptions?: ValidationOptions, ) => { return (object: any, propertyName: string) => { registerDecorator({ target: object.constructor, propertyName, options: validationOptions, constraints: [property], validator: MatchConstraint, }); }; }; @ValidatorConstraint({ name: "Match" }) export class MatchConstraint implements ValidatorConstraintInterface { validate(value: any, args: ValidationArguments) { const [fn] = args.constraints; return fn(args.object) === value; } defaultMessage(args: ValidationArguments) { const [constraintProperty]: (() => any)[] = args.constraints; return `${constraintProperty} and ${args.property} does not match`; } }Usage:
@Match(SignUpDto, (s) => s.password) passwordConfirm: string;
Really good! This example should be in the docs!
@hnbnh Cam you show ClassConstructor definition?
@bato3 I forgot to mention it, you have to import it from "class-transformer" like so:
import { ClassConstructor } from "class-transformer";
Just a bit improvements, if you like it:
import {
registerDecorator,
ValidationArguments,
ValidationOptions,
ValidatorConstraint,
ValidatorConstraintInterface,
} from 'class-validator';
import {ClassConstructor} from 'class-transformer';
export const MatchesWithProperty = <T>(
type: ClassConstructor<T>,
property: (o: T) => any,
validationOptions?: ValidationOptions,
) => {
return (object: any, propertyName: string) => {
registerDecorator({
target: object.constructor,
propertyName,
options: validationOptions,
constraints: [property],
validator: MatchConstraint,
});
};
};
@ValidatorConstraint({name: 'Match'})
export class MatchConstraint implements ValidatorConstraintInterface {
validate(value: any, args: ValidationArguments) {
const [fn] = args.constraints;
return fn(args.object) === value;
}
defaultMessage(args: ValidationArguments) {
const [constraintProperty]: Array<() => any> = args.constraints;
return `${(constraintProperty + '').split('.')[1]} and ${args.property} does not match`;
}
}
This should be in-house I guess. The solution of @PieroMacaluso is very nice.
We can receive the name of the property like string, to avoid typo we can use keyof:
import {
ValidationArguments,
ValidatorConstraint,
ValidatorConstraintInterface,
equals,
} from 'class-validator';
export const Match =
<T>(property: keyof T, options?: ValidationOptions) =>
(object: unknown, propertyName: string) =>
registerDecorator({
target: object.constructor,
propertyName,
options,
constraints: [property],
validator: MatchConstraint,
});
@ValidatorConstraint({ name: 'Match' })
export class MatchConstraint implements ValidatorConstraintInterface {
validate(value: any, validationArguments?: ValidationArguments): boolean {
return equals(validationArguments.constraints[0], value);
}
defaultMessage(validationArguments?: ValidationArguments): string {
return `${validationArguments.constraints[0]} and ${validationArguments.property} does not match`;
}
}
defaultMessage(validationArguments?: ValidationArguments): string {
const [propertyNameToCompare] = validationArguments.constraints;
return `${validationArguments.property} and ${propertyNameToCompare} does not match`;
}
}
Some slight corrections to @peixotoleonardo's really nice approach above, the complete code looks like this:
Validator:
import {
equals,
ValidationArguments,
ValidationOptions,
ValidatorConstraint,
ValidatorConstraintInterface,
registerDecorator,
} from 'class-validator';
export const Match =
<T>(property: keyof T, options?: ValidationOptions) =>
(object: any, propertyName: string) =>
registerDecorator({
target: object.constructor,
propertyName,
options,
constraints: [property],
validator: MatchConstraint,
});
@ValidatorConstraint({ name: 'Match' })
export class MatchConstraint implements ValidatorConstraintInterface {
validate(value: any, args?: ValidationArguments): boolean {
const [propertyNameToCompare] = args?.constraints || [];
const propertyValue = (args?.object as any)[propertyNameToCompare];
return equals(value, propertyValue);
}
defaultMessage(args?: ValidationArguments): string {
const [propertyNameToCompare] = args?.constraints || [];
return `${args?.property} does not match the ${propertyNameToCompare}`;
}
}
Usage:
@Match<SignupDto>('password')
confirmPassword: string;
Thanks @hnbnh and other for the solutions, this works like a charm!