Skip to content

feature: Simple Discriminator Method #1831

@DarioVisiert

Description

@DarioVisiert

Description

For my projects i have made a discriminator function to tackle 2 problems with class transformers Discrimination:

  • It is not typesafe
  • It is very verbose (especially if you combine it with class-validator and nestjs-swagger)

in this Issue i just wanted to share my solution in case some one wants to use it.
Maybe it also helps to further develop the feature.

Proposed solution

I know my solution has dependencies to class-validator and nestjs.
Because this is what is use it with and what is see the most benefit in bringing all three together.

Maybe someone has a smart solution on how such kind of solution can be integerated somewhere :)

How it can be used

I did not want to discriminate any families in the example, but that is what it ended up with.

export class FatherDetails {
  readonly type: 'father' = 'father' as const;
  readonly foo: string;
}

export class MotherDetails {
  readonly type: 'mother' = 'mother' as const;
  readonly bar: number;
}

export class ChildDetails {
  readonly type: 'child' = 'child' as const;
  readonly baz: boolean;
}

const discrimation = createDiscriminationDecorators(
  [
    { cls: FatherDetails, value: 'father' }, // Value is `somewhat` type safe only values that any of the classes in the array have can be inserted
    { cls: MotherDetails, value: 'mother' },
    { cls: ChildDetails, value: 'child' },
  ] as const, // As const is needed for the DiscriminationType to pick up on the different classes
  'type', // This is type safe only keys that all classes mentioned above share can be inserted here
);

@discrimation.ApiExtraModels // This is only needed if you use Swagger Documention in Nestjs
export class FamilyMember {
  @discrimation.Discriminated
  readonly details: DiscrimatorType<typeof discrimation>; // You do not need to type out the classes again here
}

The code

import { applyDecorators } from '@nestjs/common';
import { ApiExtraModels, ApiProperty, getSchemaPath } from '@nestjs/swagger';
import { Type, type ClassConstructor } from 'class-transformer';
import { IsIn, IsNotEmpty, IsObject, ValidateNested } from 'class-validator';

// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
export function createDiscriminationDecorators<
  BaseClass extends object,
  T extends {
    cls: ClassConstructor<BaseClass>;
    value: DiscriminatorValue;
  },
  Discriminator extends keyof InstanceType<T['cls']> & string,
  DiscriminatorValue extends InstanceType<T['cls']>[Discriminator],
>(subClasses: readonly T[], discriminator: Discriminator) {
  class Base {
    @IsIn(subClasses.map((subClass) => subClass.value))
    [discriminator]: DiscriminatorValue;
  }

  const type : {
      keepDiscriminatorProperty: boolean;
      discriminator: {
        property: Discriminator;
        subTypes: {
          value: ClassConstructor<BaseClass>;
          name: any;
        }[];
      };
    } = 
    {
      keepDiscriminatorProperty: true,
      discriminator: {
        property: discriminator,
        subTypes: subClasses.map((subClass) => ({
          value: subClass.cls,
          name: subClass.value,
        })),
      },
    };

  const apiProperty = {
    oneOf: subClasses.map((subClass) => ({
      $ref: getSchemaPath(subClass.cls),
    })),
    // eslint-disable-next-line @typescript-eslint/explicit-function-return-type
    type: () => Base,
  };

  return {
    ApiExtraModels: applyDecorators(
      ApiExtraModels(...subClasses.map((subClass) => subClass.cls)),
    ),
    Discriminated: applyDecorators(
      ValidateNested(),
      Type(() => Base, type),
      IsNotEmpty(),
      IsObject(),
      ApiProperty(apiProperty),
    ),
    _subClasses: subClasses,
  };
}

export type DiscrimatorType<
  T extends { _subClasses: readonly { cls: ClassConstructor<any> }[] },
> = InstanceType<T['_subClasses'][number]['cls']>;

export type DiscrimatorValue<T extends { _subClasses: { value: string }[] }> =
  T['_subClasses'][number]['value'];

Metadata

Metadata

Assignees

No one assigned

    Labels

    flag: needs discussionIssues which needs discussion before implementation.type: featureIssues related to new features.

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions