Search code examples
typescriptclass-validator

class validator @IsOptionalIf() or @IsRequiredIf()


I have this class

class A {
  @IsString()
  @MaxLength(99)
  prop1: string 

  @IsBoolean()
  prop2: boolean
}

I want it to be:

  1. string.
  2. required (if prop2 is true).
  3. optional if prop2 is false.

In class-validator, there's is the @ValidateIf()

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

  @IsBoolean()
  prop2: boolean
}

It's true that ValidateIf() will make prop1 optional when prop2 is false. But it will disable my checking for @MaxLength() and for @IsString(). Now you can pass a number! that's not what I want. I just want it to be optional.

I was thinking, Isn't there a way to say @IsRequiredIf() or @IsOptionalIf() instead of saying @ValidateIf()?


Solution

  • The solution is straightforward. And I made this decorator by reading the source code of the @ValidateIf() and the @IsOptional() decorators.

    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).