Search code examples
typescripttypescript-typingstypescript-genericstypescript-class

Extend generic class with additional generic parameter


I have a generic base class:

export class BaseSerializer<
  R extends boolean = false,
  M extends boolean = false,
> {
  readonly readonly: R;
  readonly many: M;

  constructor(options: { 
    readonly?: R, 
    many?: Many 
  } = {}) {
    // @ts-ignore
    this.readonly = options?.readonly || false;

    // @ts-ignore
    this.many = options?.many || false;
  }

  public fromDTO = (data: any): any => { return }
  public toDTO = (data: any): any => { return }
}

When I extend from it in a class without generics, it works as expected:

export class DateField<
  R extends boolean = false,
  M extends boolean = false,
> extends BaseSerializer<R, M> {
  fromDTO = (data: any) => new Date(data)
  toDTO = (data: any) => new Date(data).toISOString()
}

const serializer = new DateField({ many: true })
typeof serializer.many // true

But when I extend into a class with additional generic, BaseSerializer generics assignment doesn't work and R/M generics get only their default values.

export class EnumField<
  T extends any = any,
  R extends boolean = false,
  M extends boolean = false,
> extends BaseSerializer<R, M>{
  fromDTO = (data: any) => data as T
  toDTO = (data: any) => data as T
}

type T = "a" | "b" | "c"
const serializer = new EnumField<T>({ many: true, readonly: true });
// tsafe tests
assert<Equals<typeof serializer["readonly"], true>>() // Type 'false' does not satisfy the constraint 'true'
assert<Equals<typeof serializer["many"], true>>() // Type 'false' does not satisfy the constraint 'true'

Could you please suggest, how I can achieve behavior, when extended EnumField class returns T-generic value from its methods and in the same time readonly and many fields are properly settled?


Solution

  • As stated by Thomas in the comments, partial type arguments inference is not a thing in TS. There is a long standing open issue at microsoft/TypeScript#26242.

    I don't know why it hasn't been implemented, maybe because default values for type parameters have been brought to the language about at the same time and there is some overlap between the 2 features and some collision in terms of API because writing EnumField<T> could either mean "infer the rest" (which would be very very handy) or "replace the rest with default values".

    If a type parameter has a default value, there is now an ambiguity as to what to do and a sigil such as EnumField<T, infer, infer> would have to be used. That's a lot less appealing, especially given a user should not have to know how many generics the implementer is using (it doesn't need to be aligned with the number of arguments), so there may be discussions about enabling a rest syntax like EnumField<T, ...infer>, or maybe people want to add support for variadic type arguments and there is another collision there... it looks like a whole can of worms and nobody is touching it.


    TL;DR as it stands you can either pass no type parameter or every required type parameter. And any type parameter which has a default value will not be inferred but replaced by its default value.


    Workarounds

    You can solve this with a factory function which has the first generic curried

    const createEnumField = <T>() =>
      <R extends boolean, M extends boolean>(options: { readonly?: R, many?: M}) =>
        new EnumField<T, R, M>(options);
        
    const serializer = createEnumField<T>()({ many: true, readonly: true });
    

    Another option would be to add an additional fake runtime parameter to the constructor or to the options object

    const _ = null as any;
    
    export class EnumField<
      T extends any = any,
      R extends boolean = false,
      M extends boolean = false,
    > extends BaseSerializer<R, M> {
      fromDTO = (data: any) => data as T
      toDTO = (data: any) => data as T
      
      constructor(options: { readonly?: R, many?: M, returnType?: T} = {}) {
        super(options);
      }
    }
    
    type T = "a" | "b" | "c"
    
    const serializer = new EnumField({ many: true, readonly: true, returnType: <T>_ })
    

    I personally do not recommend structuring the code so that passing every type parameter is required, because in situations where the constructor arguments have been generated by a generic function or whatnot, it will not be easy for the user to supply type parameters that match the values. They can make mistakes by doing it manually or they may need/want to import some utility types to reconstruct the type arguments in a way that is synchronised to the generic function that produced the dynamic arguments.