class-validatorclass-transformer

class-validator: validating sub-types: reject invalid discriminator values, avoid looking inside arrays


How can I get class-validator to be stricter when validating sub-types - specifically, reject invalid discriminator values, and not automatically look inside arrays?

Consider the following code:

import 'reflect-metadata';
import { Equals, ValidateNested, validateOrReject } from 'class-validator';
import { plainToClass, Type } from 'class-transformer';

class Base {
  type: string;
  value: string;
}

class Derived1 extends Base {
  @Equals('value1')
  value: string;
}

class Derived2 extends Base {
  @Equals('value2')
  value: string;
}

class Data {
  @ValidateNested()
  @Type(() => Base, {
    keepDiscriminatorProperty: true,
    discriminator: {
      property: 'type',
      subTypes: [
        { value: Derived1, name: 'derived1' },
        { value: Derived2, name: 'derived2' },
      ],
    },
  })
  stuff: Base;
}

const validate = async (data: unknown) => {
  const instance = plainToClass(Data, data);
  await validateOrReject(instance);
};

(async () => {
  validate({ stuff: { type: 'derived1', value: 'value1' } });   // (1) passes as expected
  validate({ stuff: { type: 'derived2', value: 'value2' } });   // (2) passes as expected
  validate({ stuff: { type: 'derived3', value: 'value3' } });   // (3) how can I get this to throw?
  validate({ stuff: [{ type: 'derived1', value: 'value1' }] }); // (4) how can I get this to throw?
  validate({ stuff: [{ type: 'derived1', value: 'value2' }] }); // (5) this throws, expecting 'value' to equal 'value1'
})();

In (3), it seems that class-validator is perfectly happy with the invalid type field that was passed. Perhaps it is actually the class-transformer that should be expected to throw here?

In (4) I am actually surprised that this passes without any issue. Can I force class-validator / class-transformer to not automatically validate each array element as if it were a Base?


Solution

  • You can make your example work by using the @IsString(), @IsIn() and @IsObject() decorators:

    import 'reflect-metadata';
    import { Equals, ValidateNested, validateOrReject } from 'class-validator';
    import { plainToClass, Type } from 'class-transformer';
    
    class Base {
      @IsString()
      @IsIn(['derived1', 'derived2'])
      type: string;
      value: string;
    }
    
    class Derived1 extends Base {
      @Equals('value1')
      value: string;
    }
    
    class Derived2 extends Base {
      @Equals('value2')
      value: string;
    }
    
    class Data {
      @IsObject()
      @ValidateNested()
      @Type(() => Base, {
        keepDiscriminatorProperty: true,
        discriminator: {
          property: 'type',
          subTypes: [
            { value: Derived1, name: 'derived1' },
            { value: Derived2, name: 'derived2' },
          ],
        },
      })
      stuff: Base;
    }
    
    const validate = async (data: unknown) => {
      const instance = plainToClass(Data, data);
      await validateOrReject(instance);
    };
    
    (async () => {
      validate({ stuff: { type: 'derived1', value: 'value1' } });   // (1) passes as expected
      validate({ stuff: { type: 'derived2', value: 'value2' } });   // (2) passes as expected
      validate({ stuff: { type: 'derived3', value: 'value3' } });   // (3) how can I get this to throw?
      validate({ stuff: [{ type: 'derived1', value: 'value1' }] }); // (4) how can I get this to throw?
      validate({ stuff: [{ type: 'derived1', value: 'value2' }] }); // (5) this throws, expecting 'value' to equal 'value1'
    })();
    

    So:

    1. With IsObject() you are forcing stuff to be an object and your forbid arrays.
    2. With IsIn() you are forcing type to be one of your options ('derived1' | 'derived2'), anything else will fail.
    3. Notice you should always use keepDiscriminatorProperty: true to make it work, if not, @IsIn will fail since type key won't be present on type key validation process.