Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Mapping generic spreaded argument types in TypeScript

I'm trying to write a generic method that takes any number of arguments that are keys to an object and use the values of their keys as arguments to a constructor. This is my original implementation:

// Typescript 2.x
export function oldMethod<TProps>() {
  function create<
    TInstance extends Geometry | BufferGeometry,
  >(
    geometryClass: new () => TInstance,
  ): any;
  function create<
    TInstance extends Geometry | BufferGeometry,
    TKey1 extends Extract<keyof TProps, string>,
  >(
    geometryClass: new (param1: TProps[TKey1]) => TInstance,
    key1: TKey1,
  ): any;
  function create<
    TInstance extends Geometry | BufferGeometry,
    TKey1 extends Extract<keyof TProps, string>,
    TKey2 extends Extract<keyof TProps, string>,
  >(
    geometryClass: new (param1: TProps[TKey1], param2: TProps[TKey2]) => TInstance,
    key1: TKey1,
    key2: TKey2,
  ): any;
  function create<
    TInstance extends Geometry | BufferGeometry,
    TKey1 extends Extract<keyof TProps, string>,
    TKey2 extends Extract<keyof TProps, string>,
    TKey3 extends Extract<keyof TProps, string>,
  >(
    geometryClass: new (param1: TProps[TKey1], param2: TProps[TKey2], param3: TProps[TKey3]) => TInstance,
    key1: TKey1,
    key2: TKey2,
    key3: TKey3,
  ): any;
  // ...all the way up to 8 possible keys
  function create<TInstance extends Geometry | BufferGeometry>(
    geometryClass: new (...args: Array<TProps[Extract<keyof TProps, string>]>) => TInstance,
    ...args: Array<Extract<keyof TProps, string>>) {
    class GeneratedGeometryWrapper extends GeometryWrapperBase<TProps, TInstance> {
      protected constructGeometry(props: TProps): TInstance {
        return new geometryClass(...args.map((arg) => props[arg]));
      }
    }

    return class GeneratedGeometryDescriptor extends WrappedEntityDescriptor<GeneratedGeometryWrapper,
      TProps,
      TInstance,
      GeometryContainerType> {
      constructor() {
        super(GeneratedGeometryWrapper, geometryClass);

        this.hasRemountProps(...args);
      }
    };
  }
  return create;
}

With the announcement of extracting and spreading parameter lists with tuples in TypeScript 3.0, I was hoping I would be able to remove the overloads to make it a lot simpler as such:

// Typescript 3.x
export function newMethod<TProps>() {
  function create<TInstance extends Geometry | BufferGeometry, TArgs extends Array<Extract<keyof TProps, string>>>(
    geometryClass: new (...args: /* what goes here? */) => TInstance,
    ...args: Array<Extract<keyof TProps, string>>) {
    class GeneratedGeometryWrapper extends GeometryWrapperBase<TProps, TInstance> {
      protected constructGeometry(props: TProps): TInstance {
        return new geometryClass(...args.map((arg) => props[arg]));
      }
    }

    return class GeneratedGeometryDescriptor extends WrappedEntityDescriptor<GeneratedGeometryWrapper,
      TProps,
      TInstance,
      GeometryContainerType> {
      constructor() {
        super(GeneratedGeometryWrapper, geometryClass);

        this.hasRemountProps(...args);
      }
    };
  }
  return create;
}

However, I don't know what to put as the type for the args that define the type of the constructor. If I was able to manipulate types like I can objects in JavaScript, I would write it as such: ...[...TArgs].map(TArg => TProps[TArg], but obviously that's not valid TypeScript syntax and I can't think of any way to do it. Am I missing a way to express this type? Is there some way of making this completely type-safe without having to have the function overloads and a finite number of arguments? Is there some TypeScript feature that's missing that would allow me to express this type?

like image 911
Nathan Bierema Avatar asked Aug 08 '18 00:08

Nathan Bierema


1 Answers

I stripped out a lot of code for the following example, but it should be in the same spirit.

The feature you are missing is called mapped arrays/tuples which is planned to be released in TypeScript 3.1 sometime in August 2018. You will be able to map arrays and tuples just like you map other types, like this:

type Mapped<T> = {[K in keyof T]: Array<T[K]>};
type Example = Mapped<[string, number, boolean]>; 
// type Example = [string[], number[], boolean[]];

And if you use typescript@next right now you can try that out.


In your case what you'd want to do is something like

type MappedArgs = {[K in keyof TArgs]: TProps[TArgs[K]]};
type ConstructorType = new (...args: MappedArgs) => any;

But there are a few outstanding issues preventing you from doing that. One is that for some reason the compiler doesn't yet understand that TArgs[K] is a valid index to TProps. So you can introduce a conditional type that allows you to work around that:

type Prop<T, K> = K extends keyof T ? T[K] : never;
type MappedArgs = {[K in keyof TArgs]: Prop<TProps,TArgs[K]>};

But the following still doesn't work:

type ConstructorType = new (...args: MappedArgs) => any;
// error, a rest parameter must be of an array type

Hmm, MappedArgs sure is an array type, but TypeScript doesn't realize it. Can't do this to convince it either:

type MappedArgs = {[K in keyof TArgs]: Prop<TProps,TArgs[K]>} 
  & unknown[]; // definitely an array!

type ConstructorType = new (...args: MappedArgs) => any;
// error, a rest parameter must be of an array type 

This appears to be an outstanding bug in mapped arrays/tuples in which the mapped type is not seen as an array everywhere. This is likely to get fixed by the release of TypeScript 3.1. For now, you can do a workaround by adding a new dummy type parameter, as in

type Prop<T, K> = K extends keyof T ? T[K] : never;
type MappedArgs = {[K in keyof TArgs]: Prop<TProps,TArgs[K]>}
  & unknown[]; // definitely an array!
type ConstructorType<A extends MappedArgs = MappedArgs> = new (...args: A) => any;

and that works. Let's see if we can test this thing. How about:

type Prop<T, K> = K extends keyof T ? T[K] : never;
interface NewMethod<TProps> {
  create<TArgs extends Array<Extract<keyof TProps, string>>,
    MTArgs extends unknown[] & { [K in keyof TArgs]: Prop<TProps, TArgs[K]> }>(
      geometryClass: new (...args: MTArgs) => any,
      ...args: Array<Extract<keyof TProps, string>>): void;
}

declare const z: NewMethod<{ a: string, b: number }>;
z.create(null! as new (x: string, y: number) => any, "a", "b"); // okay
z.create(null! as new (x: string, y: number) => any, "a", "c"); // error, "c" is bad
z.create(null! as new (x: string, y: boolean) => any, "a", "b"); // error, constructor is bad

Those seem to act the way you want... although the error in the last case is really obscure and doesn't seem to point out that the problem is that the type of the y parameter is boolean and doesn't match string or number from TProps[keyof TProps].


Anyway, this is still bleeding-edge stuff as of Aug 2018, so I think you might have to wait a little while before it settles down to see exactly how it will work. Hope that helps. Good luck!

like image 195
jcalz Avatar answered Oct 16 '22 16:10

jcalz