Home > OS >  How to infer generic type based on provided Record values in typescript?
How to infer generic type based on provided Record values in typescript?

Time:11-09

I have a polymorficFactory function, that will instantiate classes based on the 'discriminator' property provided:

type ClassConstructor<T> = {
    new (...args: any[]): T;
};
type ClassMap<T> = Record<string, ClassConstructor<T>>;

function polymorficFactory<T extends object>(
  classMap: ClassMap<T>,
  discriminator: string,
  input: Record<string, any>,
): T {
  if (!input[discriminator]) throw new Error('Input does not have a discriminator property');

  const discriminatorValue = input[discriminator];
  const constructor = classMap[discriminatorValue];

  return plainToInstance(constructor, input); // class-transformer util
}

It's used on unknown objects coming from request payloads:

const MOCK_PAYLOAD = {
  type: 'SOME_DTO',
  someProperty: 1,
  someStr: 'Lorem',
  someNestedProp: {
    someBool: true,
  }
} as as Record<string, unknown>; // Record<string, unknown/any>, since comes from JSON payload, but will always be an object
const dto = polymorficFactory<SomeDto | SomeOtherDto>(
  {
    SOME_DTO: SomeDto,
    SOME_OTHER_DTO: SomeOtherDto,
  },
  'type',
  MOCK_PAYLOAD ,
);

dto; // when hovering, type will be "SomeDto | SomeOtherDto"

If I wont provide the type parameters:

const dto = polymorficFactory(
  ...
);

dto; // when hovering, type will be "SomeDto"

It selects the first value it finds in this object:

{
  SOME_DTO: SomeDto,
  SOME_OTHER_DTO: SomeOtherDto,
}

Is there a way that I can make ts infer the union type, SomeDto | SomeOtherDto or any other Dto classes found in the provided classMap, from the map values, without explicitly providing it?

I.e., for the following classMap:

{
  A: aDto,
  B: bDto,
  C: cDto,
}

I'd expect the following union type inferred for the result:
aDto | bDto | cDto

Playground link

CodePudding user response:

You need to play with inference on function arguments a little bit. The rule of thumb: If you want to infer a value, you should replay all transformations which are done in a runtime in type scope.

I mean if you build a class instance, you need to call InstanceType built in type in order to get class instance type.

These class maps worth making immutable or in other words use as const assertion.

COnsider this:

import { plainToInstance } from "class-transformer";

type ClassConstructor<T> = {
  new(...args: any[]): T;
};

function polymorficFactory<
  Inst,
  Values extends PropertyKey,
  Discriminator extends string,
  ClassMap extends Record<Values, ClassConstructor<Inst>>,
  Input extends Record<Discriminator, Values>
>(
  classMap: ClassMap,
  discriminator: Discriminator,
  input: Input,

): InstanceType<ClassMap[Input[Discriminator]]>
function polymorficFactory<
  Inst,
  Values extends PropertyKey,
  Discriminator extends string,
  ClassMap extends Record<Values, ClassConstructor<Inst>>,
  Input extends Record<Discriminator, Values>
>(
  classMap: ClassMap,
  discriminator: Discriminator,
  input: Input,

) {
  if (!input) throw new Error('Input is not an object');
  if (!input[discriminator]) throw new Error('Input does not have a discriminator property');

  const discriminatorValue = input[discriminator];
  const klass = classMap[discriminatorValue];
  const result = plainToInstance(klass, input)

  return result
}

class SomeDto {
  tag = 'SomeDto'
}

class SomeOtherDto {
  tag = 'SomeOtherDto'
}

const CLASS_MAP = {
  SOME_DTO: SomeDto,
  SOME_OTHER_DTO: SomeOtherDto,
} as const

const MOCK = {
  type: 'SOME_OTHER_DTO',
  someProperty: 1,
} as const

const explicitParams = polymorficFactory(
  CLASS_MAP,
  'type',
  MOCK,
);

explicitParams; // when hovering, type will be " SomeOtherDto"

Playground

As you might have noticed each value in polymorficFactory is infered properly.

  1. See this line const discriminatorValue = input[discriminator]; . If you expect discriminator to be a valid index for input, you should apply appropriate constraint:
function polymorficFactory<
  // .....
  Values extends PropertyKey,
  Discriminator extends string,
  Input extends Record<Discriminator, Values>
>(
  // ...
  discriminator: Discriminator,
  input: Input,
) 

input variable should extend Record<Discriminator, Values>. It means that discriminator is allowed index for input.

Consider this line const klass = classMap[discriminatorValue];

discriminatorValue which is input[discriminator] should be a valid key for classMap, hence, we need to apply here an appropriate constraint:

function polymorficFactory<
  Inst,
  Values extends PropertyKey,
  Discriminator extends string,
  ClassMap extends Record<Values, ClassConstructor<Inst>>,
  Input extends Record<Discriminator, Values>
>(
  classMap: ClassMap,
  discriminator: Discriminator,
  input: Input,

)

See Values generic, it plays a role of value in Input and a key in ClassMap. It is important.

So now, when all arguments are infered, we need to infer a return type. Our return type is an instance of classMap[input[discriminator]], or in type words: InstanceType<ClassMap[Input[Discriminator]]>

If you are interested in more explanation of inference on function arguments, you can check my article

  • Related