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?

1

There are 1 best solutions below

0
On BEST ANSWER

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.