Validate each Map<string, number> value using class-validator

Viewed 1328

I'm trying to perform simple validation on a JSON input, modelled by one of my DTOs. One of the object properties is of type Map<string, number>. an example input:

{
  "type": "CUSTOM",
  "is_active": true,
  "current_plan_day": 1,
  "custom_warmup_plan": {
    "1": 123,
    "2": 456
}

On my controller I'm using a DTO to specify the body type. the class, together with class-validator decorators is this:

    export class CreateWarmupPlanRequestDto {
      @IsEnum(WarmupPlanType)
      type: string;
    
      @IsOptional()
      @IsNumber({ allowInfinity: false, allowNaN: false, maxDecimalPlaces: 0 })
      @IsPositive()
      hard_cap: number | null;
    
      @IsBoolean()
      is_active: boolean;
    
      @IsNumber({ allowInfinity: false, allowNaN: false, maxDecimalPlaces: 0 })
      @IsPositive()
      current_plan_day: number;
    
      @IsOptional()
      @IsNumber({ allowInfinity: false, allowNaN: false, maxDecimalPlaces: 0 })
      @IsPositive()
      previous_plan_day: number | null;
    
      @IsOptional()
      @IsNumber({ allowInfinity: false, allowNaN: false, maxDecimalPlaces: 0 }, { each: true })
      @IsPositive({ each: true })
      custom_warmup_plan: Map<string, number>;  // PROBLEM HERE
    }

I'm looking to validate each value of custom_warmup_plan to be an existing positive integer. Validation of the other properties of the object works just fine and as expected, but for my example input I keep getting errors (2 error messages, joined):

{
    "message": "each value in custom_warmup_plan must be a positive number. |#| each value in custom_warmup_plan must be a number conforming to the specified constraints",
    "statusCode": 400,
    "timestamp": "2021-07-29T13:18:29.331Z",
    "path": "/api/warmup-plan/bc4c3f0e-8e77-46de-a46a-a908edbdded5"
}

Documentation for this seems to be pretty straight forward, but I just cant get it to work. I've also played around with a simple Map<string, string> and the @IsString(each: true) validator, but that does not seem to work either.

any ideas?

versions:

"@nestjs/common": "^8.0.0",
"@nestjs/core": "^8.0.0",
"@nestjs/mapped-types": "^1.0.0",
"@nestjs/platform-express": "^8.0.0",
"class-transformer": "^0.4.0",
"class-validator": "^0.13.1",
2 Answers

From the docs

If your field is an array and you want to perform validation of each item in the array you must specify a special each: true decorator option

If you want to be able to validate maps you could write a custom decorator and pass in a list of class-validator functions to validate the keys and values. For example the below decorator takes as input a list of validation functions for both the keys and values (e.g. passing in isString, isObject, etc..., class-validator has a corresponding function you can call for all the validation decorators they provide)

export function IsMap(
  key_validators: ((value: unknown) => boolean)[],
  value_validators: ((value: unknown) => boolean)[],
  validationOptions?: ValidationOptions
) {
  return function (object: unknown, propertyName: string) {
    registerDecorator({
      name: 'isMap',
      target: (object as any).constructor,
      propertyName: propertyName,
      options: validationOptions,
      validator: {
        validate(value: unknown, args: ValidationArguments) {
          if (!isObject(value)) return false;
          const keys = Object.keys(value);
          const is_invalid = keys.some((key) => {
            const is_key_invalid = key_validators.some((validator) => !validator(key));
            if (is_key_invalid) return false;

            const is_value_invalid = value_validators.some((validator) => !validator(value[key]));
            return is_value_invalid;
          });

          return is_invalid;
        },
      },
    });
  };
}

And you can use this decorator in your example like this

import { isInt } from 'class-validator'
export class CreateWarmupPlanRequestDto {
  @IsOptional()
  @IsMap([], [isInt])
  custom_warmup_plan: Map<string, number>;
}

It is necessary to convert plain object to map. Use Transform decorator from class-transformer

@IsOptional()
@IsNumber(undefined, { each: true })
@Transform(({ value }) => new Map(Object.entries(value)))
prop?: Map<string, number>;
Related