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

[Feature] @IsOptionaIf()

Open ibox4real opened this issue 5 years ago • 9 comments

It would be nice if we could have conditional decorator for required/non-required properties.

Example use case:

I want to insert or update a record. When inserting a new row the title is required, but when updating the releaseYear on existing row the title column is not required.

I could get around this by creating two different classes InsertRecordParameters and UpdateRecordParamaeters but it would be nice to have eveything in one class when insert and update do not differ in terms of the parameters used.

class SaveRecordParameters {
@IsOptional() 
@IsPositive()
 id?: number;
 
@IsOptionaIf( self => !!self.id )
@MaxLength(5)
@MinLength(50)
 title?: string;

@IsOptional()
@IsNumber()
 releaseYear?: number;
}

ibox4real avatar Jul 09 '20 13:07 ibox4real

@ibox4real you can use Validation groups. They are meant exactly for what you are looking for.

Example class: A class that I used in a project here


export enum RecordParametersValidationGroup {
  CREATION = 'creation',
  UPDATE = 'update',
};

export defaul class SaveRecordParameters {
@IsOptional() 
@IsPositive()
 id?: number;
 
@Length(5, 50, { groups: [RecordParametersValidationGroup.UPDATE] }])
 title?: string;

@IsOptional({ groups: [RecordParametersValidationGroup.UPDATE] })
@IsNumber()
 releaseYear?: number;
}

Example usage: The usage here

import {validate} from "class-validator";
import { RecordParametersValidationGroup } from '@entity/SaveRecordParameters ';

// ...

validate(an_example_instance_of_record_parameters, {
    groups: [RecordParametersValidationGroup.UPDATE]
});

carlocorradini avatar Jul 10 '20 08:07 carlocorradini

There's a pull request for a conditional @IsOptional decorator here: https://github.com/typestack/class-validator/pull/196 But it hasn't been merged in over two years now.

ekrismer avatar Jul 13 '20 13:07 ekrismer

Looks way simpler when you have an IsOptionalIf

c0mradeuc avatar Jul 29 '20 18:07 c0mradeuc

You can achieve this by creating your own decorator that wraps @ValidateIf.

/**
 * Mark the property as optional if the function returns truthy
 * 
 * @param optionalIfPropertyIsSet
 */
function IsOptionalIf(allowOptional: (obj: any, value: any) => boolean, options?: ValidationOptions) {
  // If required, do validate. Otherwise if null|undefined, don't validate
  return ValidateIf((obj, value) => !allowOptional(obj, value) || value != null), options)
}

class SaveRecordParameters {
  @IsOptional() 
  @IsPositive()
  id?: number;

  @IsOptionalIf(self => !!self.id)
  @MaxLength(5)
  @MinLength(50)
  title?: string;

  @IsOptional()
  @IsNumber()
  releaseYear?: number;
}

NickKelly1 avatar Aug 26 '20 01:08 NickKelly1

Can someone make this PR merged?? this will be so helpful!!

I made my version of IsOptionalIf run validators is condition is false if condition is true, don't run validators if value is null or undefined. else run validators

import {
  registerDecorator,
  ValidationOptions,
  ValidationTypes,
} from 'class-validator';
import { isNil } from 'lodash';

export function IsOptionalIf(
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  isOptionalCondition: (object: any) => boolean,
  validationOptions?: ValidationOptions,
): PropertyDecorator {
  return function (object, propertyName: string) {
    const constraint = (obj, val) => {
      const conditionResult = isOptionalCondition(obj);
      if (conditionResult) {
        // OPTIONAL
        return !isNil(val); // validate if value is defined and not validate when value is undefined
      }

      return true; // validate anyway
    };

    registerDecorator({
      name: ValidationTypes.CONDITIONAL_VALIDATION, // name must be exactly this value to use only constraint
      target: object.constructor,
      propertyName: propertyName,
      constraints: [constraint],
      options: validationOptions,
      validator: {
        validate() {
          return false; // can be true or false, because it's not used
        },
      },
    });
  };
}

may be it will help someone

TimurKastemirov avatar May 11 '24 10:05 TimurKastemirov

heres a really simple solution:

Validator:

import { IsOptional } from "class-validator";

export function IsOptionalIf(condition: boolean) {
    if (!condition) {
        return () => {};
    }
    return IsOptional()
  }

Usage:

@IsOptionalIf(configuration.USERS.REQUIRE_BIRTH_DATE === false)
@IsDateString()
@IsNotEmpty()
birth_date?: string;

This will result in the exact desired behaviour and only add IsOptional if the condition is true.

Alfagun74 avatar Jul 23 '24 13:07 Alfagun74

heres a really simple solution:

Validator:

import { IsOptional } from "class-validator";

export function IsOptionalIf(condition: boolean) {
    if (!condition) {
        return () => {};
    }
    return IsOptional()
  }

Usage:

@IsOptionalIf(configuration.USERS.REQUIRE_BIRTH_DATE === false)
@IsDateString()
@IsNotEmpty()
birth_date?: string;

This will result in the exact desired behaviour and only add IsOptional if the condition is true.

Not exactly. your solution doesn't work with props inside the object which you want to check for example you have UserDTO with props firstName, lastName, role, email email is required only if role equals to 'Admin' here I don't know how to use your solution

TimurKastemirov avatar Jul 23 '24 15:07 TimurKastemirov

I answered this on StackOverflow, the solution is straightforward. And I made this decorator by reading the source code of the @ValidateIf() and the @IsOptional() decorators.

Here is my thread: CLICK ME

Here is the solution:

I created my own IsOptionalIf() decorator. You can use the same concept to build your own IsRequiredIf().

New Answer

You can use the @ValidateIf() decorator to achieve that, but you have to create a new decorator around it. You can't use @ValidateIf() directly, because it disables all the validations which is not what you might want. You want to keep the validations but only make the property optional.

This is what this custom IsOptionalIf() decorator allows you to do, It behaves exactly like the normal @IsOptional() decorator but allows you to add a condition.

import { ValidateIf, type ValidationOptions } from 'class-validator'

/** Same as `@Optional()` decorator of class-validator, but adds a conditional layer on top of it */
export const IsOptionalIf: IsOptionalIf =
  (condition, options = {}) =>
  (target: object, propertyKey: string) => {
    const { allowNull = true, allowUndefined = true, ...validationOptions } = options
    ValidateIf((object: any, value: any): boolean => {
      // if condition was true, just disable the validation on the null & undefined fields
      const isOptional = Boolean(condition(object, value))
      const isNull = object[propertyKey] === null
      const isUndefined = object[propertyKey] === undefined
      let isDefined = !(isNull || isUndefined)
      if (!allowNull && allowUndefined) isDefined = !isUndefined
      if (!allowUndefined && allowNull) isDefined = !isNull

      const isRequired = isOptional && !isDefined ? false : true
      return isRequired
    }, validationOptions)(target, propertyKey)
  }

export interface OptionalIfOptions {
  allowNull?: boolean
  allowUndefined?: boolean
}

export type IsOptionalIf = <
  T extends Record<string, any> = any, // class instance
  Y extends keyof T = any, // propertyName
>(
  condition: (object: T, value: T[Y]) => boolean | void,
  validationOptions?: ValidationOptions & OptionalIfOptions
) => PropertyDecorator

Here is how you can use it:

class A {
  @IsString()
  @MaxLength(99)
  @IsOptionalIf((obj) => obj.prop2)
  prop1: string

  @IsBoolean()
  prop2: boolean
}

You can consider null as a real value instead of treating it as not filled by adding the options object as below:

{
// same as above
  @IsOptionalIf((obj) => obj.prop2, { allowNull: false })
  prop1: string
}

Old answer

import { type ValidationMetadataArgs } from 'class-validator/types/metadata/ValidationMetadataArgs'
import { type ValidationOptions, ValidationTypes, getMetadataStorage } from 'class-validator'
import { ValidationMetadata } from 'class-validator/cjs/metadata/ValidationMetadata'

export const IS_OPTIONAL_IF = 'isOptionalIf'

export function IsOptionalIf(
  condition: (object: any, value: any) => boolean,
  validationOptions?: ValidationOptions
): PropertyDecorator {
  return function (object: object, propertyName: string): void {
    const args: ValidationMetadataArgs = {
      type: ValidationTypes.CONDITIONAL_VALIDATION,
      name: IS_OPTIONAL_IF,
      target: object.constructor,
      propertyName: propertyName,
      constraints: [
        (object: any, value: any): boolean => {
          const performValidation = condition(object, value)
          if (!performValidation) return true
          else return object[propertyName] !== null && object[propertyName] !== undefined
        },
      ],
      validationOptions: validationOptions,
    }
    getMetadataStorage().addValidationMetadata(new ValidationMetadata(args))
  }
}

Regarding the ValidationMetadata import, I am using commonjs, so if you're using esm5 (es5) or es2015 (es6+) just replace cjs in the import to yours.

example:

import { ValidationMetadata } from 'class-validator/cjs/metadata/ValidationMetadata'
import { ValidationMetadata } from 'class-validator/esm5/metadata/ValidationMetadata'
import { ValidationMetadata } from 'class-validator/esm2015/metadata/ValidationMetadata'

The reason for this entire method is we want to use the ValidationTypes.CONDITIONAL_VALIDATION type of validation, there are some others, but class-validator gives you only the ValidationTypes.CUSTOM_VALIDATION when you create a custom validator using the registerDecorator() function. It assumes that by default. And that can not be changed. (as of the current version 2024).

samislam avatar Aug 03 '24 11:08 samislam