class-validator icon indicating copy to clipboard operation
class-validator copied to clipboard

Is it possible to validate if only one of a group of properties are defined?

Open linck5 opened this issue 6 years ago • 10 comments

Suppose I have something like this.

class UpdateUserDTO {

  @IsString()
  readonly status: string;

  @IsBoolean()
  readonly deleted: boolean;

  @IsString()
  readonly name: string;
}

I want to validate this so that the class can only have either status or deleted defined. It doesn't matter if name is defined or not, but if status is defined, then deleted cannot be defined and vice versa.

Any way to make that work?

linck5 avatar Jul 27 '18 17:07 linck5

https://github.com/typestack/class-validator#conditional-validation

MichalLytek avatar Jul 27 '18 18:07 MichalLytek

Yes I was thinking if there was some way I could use @ValidateIf, but I don't see how yet. I could put @ValidateIf(o => o.status != undefined) on deleted, and have something that would always fail the validation. But that would be a bit of a clunky solution, and I still want to perform other validations like @IsBoolean even if status is undefined.

linck5 avatar Jul 27 '18 20:07 linck5

It's been a while since this issue was created, but here's the solution I came up with for this use case. I had the same requirement, and it seems like a pretty common requirement.

What you're looking to do would currently require the combination of a custom validator and ValidateIf. You end up with two validations, one validates if there a property present that cannot exist on the same instance as the validated property, and the other determines if a property should be validated.

// Define new constraint that checks the existence of sibling properties
@ValidatorConstraint({ async: false })
class IsNotSiblingOfConstraint implements ValidatorConstraintInterface {

  validate(value: any, args: ValidationArguments) {
    if (validator.isDefined(value)) {
      return this.getFailedConstraints(args).length === 0
    }
    return true;
  }

  defaultMessage(args: ValidationArguments) {
    return `${args.property} cannot exist alongside the following defined properties: ${this.getFailedConstraints(args).join(', ')}`
  }

  getFailedConstraints(args: ValidationArguments) {
    return args.constraints.filter((prop) => validator.isDefined(args.object[prop]))
  }
}

// Create Decorator for the constraint that was just created
function IsNotSiblingOf(props: string[], validationOptions?: ValidationOptions) {
  return function (object: Object, propertyName: string) {
    registerDecorator({
      target: object.constructor,
      propertyName: propertyName,
      options: validationOptions,
      constraints: props,
      validator: IsNotSiblingOfConstraint
    });
  };
}

// Helper function for determining if a prop should be validated
function incompatibleSiblingsNotPresent(incompatibleSiblings: string[]) {
  return function (o, v) {
    return Boolean(
      validator.isDefined(v) || // Validate if prop has value
      incompatibleSiblings.every((prop) => !validator.isDefined(o[prop])) // Validate if all incompatible siblings are not defined
    )
  }
}

Your class

class UpdateUserDTO {

  @IsString()
  @IsNotSiblingOf(['deleted'])
  @ValidateIf(incompatibleSiblingsNotPresent(['deleted']))
  readonly status: string;

  @IsBoolean()
  @IsNotSiblingOf(['status'])
  @ValidateIf(incompatibleSiblingsNotPresent(['status']))
  readonly deleted: boolean;

  @IsString()
  readonly name: string;
}

Note: there are definitely some improvements that can be made to this, but as a quick example it should get the job done.

If you wanted to you could wrap these two decorators in a decorator to make it a one line validation definition.

halcarleton avatar Apr 22 '19 20:04 halcarleton

Extending @halcarleton 's work above as suggested to combine the two decorators, the following works for me:

export function IncompatableWith(incompatibleSiblings: string[]) {
  const notSibling = IsNotSiblingOf(incompatibleSiblings);
  const validateIf = ValidateIf(
    incompatibleSiblingsNotPresent(incompatibleSiblings)
  );
  return function(target: any, key: string) {
    notSibling(target, key);
    validateIf(target, key);
  };
}
class UpdateUserDTO {

  @IncompatableWith(['deleted'])
  @IsString()
  readonly status: string;
  
  @IncompatableWith(['status'])
  @IsBoolean()
  readonly deleted: boolean;

  @IsString()
  readonly name: string;

KieranHarper avatar Oct 29 '19 23:10 KieranHarper

Naive question @halcarleton but where does the validator in validator.isDefined(value) come from? I don't see that function in this validator package: https://github.com/validatorjs/validator.js. Is there another?

piersmacdonald avatar Aug 07 '20 23:08 piersmacdonald

@piersmacdonald validator.isDefined(value) is part of this package: https://github.com/typestack/class-validator/blob/develop/src/decorator/common/IsDefined.ts

NoNameProvided avatar Aug 08 '20 01:08 NoNameProvided

Thanks, @KieranHarper, and @halcarleton. I made an npm package of this solution to make it easier for others to use. https://www.npmjs.com/package/incompatiblewith

alirezabonab avatar Mar 14 '21 00:03 alirezabonab

You can do this in very simple way using @MichalLytek suggestion, like this:

@ValidateIf(obj => !obj.order || obj.item)
item: string

@ValidateIf(obj => !obj.item || obj.order)
order: string

mrfy avatar Sep 21 '21 09:09 mrfy

May be my solution for 3 fields, will be helpful to someone

@IsOptional()
aField: string;

@IsOptional()
bField: string;

@IsOptional()
cField: string;

// validation that only 1 (or 0 - zero props is ok too) of 3 props must present
@ValidateIf((object) => {
  if (object.aField && (object.bField || object.cField)) {
    return true;
  }
  if (object.bField && (object.aField || object.cField)) {
    return true;
  }
  return object.cField && (object.bField || object.aField);
})
@IsDefined({
  message: 'Only one field from fields (aField, bField, cField) required!',
})
protected readonly onlyOneFieldAllowed: undefined;

ai-leonid avatar Apr 25 '24 09:04 ai-leonid

Here is a simple solution for two fields, if this is your situation:

  • No fields present: OK
  • Exactly one field present: OK
  • Both fields present: FAIL

All we need is a decorator that automatically rejects if a certain other field is defined. That decorator will naturally be bypassed by @IsOptional(), so it works out.

import { registerDecorator } from 'class-validator';

/**
 * Fails if `otherField` is neither null nor undefined.
 * Should be used together with IsOptional() which will bypass this rule.
 */
export function BlockOtherField(otherField: string) {
  return function (object: any, propertyName: string) {
    registerDecorator({
      name: 'blockOtherField',
      target: object.constructor,
      propertyName,
      validator: {
        defaultMessage() {
          return `${otherField} may not be set`;
        },
        validate() {
          if (![null, undefined].includes(object[otherField])) {
            return false;
          }

          return true;
        },
      },
    });
  };
}

Use it like this:

class MyDTO {
  @IsOptional()
  @BlockOtherField('fieldTwo')
  @IsUUID()
  fieldOne?: string | null;

  @IsOptional()
  @BlockOtherField('fieldOne')
  @IsUUID()
  fieldTwo?: string | null;
}

In fact, it suffices to only use BlockOtherField on the first field.

vincentrolfs avatar Jun 07 '24 09:06 vincentrolfs