Search code examples
javascripttypescripttypescript-typingstypescript-genericstypescript-class

Typescript: converting type of array of classes to type of array of constructed classes


there is a problem I've been struggling to solve for quite a time.

I have a function, inside which I put two arguments:

  • classes - array of classes
  • mapper - function that takes an array of instances of these classes.

Imagine the function like this:

const MapClasses = (classes, mapper) => {
  const instances = classes.map((item) => new item())
  return mapper(instances)
}

How could I create a typescript declaration for this, to ensure that mapper will always accept an array of instances of classes.

Example usage:

class Coords {
  x = 10
  y = 10
}

class Names {
  name = 'Some Name'
}

const mapper = ([coords, names]) => {
  return {
    x: coords.x,
    y: coords.y,
    myName: names.name,
  }
}

const mapped = MapClasses([Coords, Names], mapper)
// { x: 10, y: 10, myName: 'Some Name'}

So I think it should be possible, to check if mapper is accessing the right values.

I've got it working kinda this way:

type MapClasses = <Classes, Mapped>(
  classes: {
    [Property in keyof Classes]: new () => Classes[Property]
  },
  mapper: (instances: Classes) => Mapped,
) => Mapped

However in this case, errors show only on classes parameter, not the mapper.

So is there any way to turn this behaviour around?
....

I will be thankful for any ideas.

Have a nice day.


Solution

  • To be clear, your problem is that when you call MapClasses() incorrectly, the error appears on the classes parameter and not on the mapper parameter:

    MapClasses([Names, Coords], mapper) // error
    // -------> ~~~~~  ~~~~~~ <---
    // |                          |
    // |  Type 'typeof Coords' is not assignable to type 'new () => Names'.
    // Type 'typeof Names' is not assignable to type 'new () => Coords',
    

    This isn't wrong, but you would prefer it to happen the other way, like this:

    MapClasses([Names, Coords], mapper) // error
    // -----------------------> ~~~~~~
    // Type '[Names, Coords]' is not assignable to type '[Coords, Names]'.
    

    In order to get this to happen, we need to change the signature of MapClasses so that the generic type parameter is inferred from classes and not from mapper. This means we need classes to have a higher priority inference site for the type parameter than mapper does. The details of how the compiler chooses which values to infer types from are not really documented anywhere official; there is a section of the outdated TypeScript spec that goes over what it used to be at some time, though. A good rule of thumb is that the compiler will choose the value which is related to the type parameter in the "simplest" way.

    So we need to refactor the call signature so that the type annotation for classes is related to the type parameter more simply than the type annotation for mapper is. Here's one way to do it:

    const MapClasses = <C extends (new () => any)[], M>(
        classes: [...C],
        mapper: (instances: ElementInstanceType<C>) => M,
    ): M => {
        const instances = classes.map((item) => new item()) as
            ElementInstanceType<C>;
        return mapper(instances)
    }
    
    type ElementInstanceType<C> =
        { [K in keyof C]: C[K] extends new () => infer R ? R : never };
    

    The type parameter C is the one we are concerned with, and I have constrained it to be an array of construct signatures. Conceptually classes is just of type C, although I have written [...C] using a variadic tuple type to give the compiler a hint that we would like to infer classes as a tuple and not as an unordered array.

    Meanwhile mapper is of type (instance: ElementInstanceType<C>) => M, where ElementInstanceType is a mapped type whose properties are conditional types. It turns a tuple of construct signature types into a tuple of their corresponding instance types. Compare [...C] to (instance: ElementInstanceType<C>) => M and you can see that the former is more simply related to C than the latter.

    And that means when you call MapClasses, the type parameter C will tend to be inferred from classes and just checked against mapper.


    Let's make sure it works. First of all we need to see that the non-error case still results in a value of the right type:

    const mapped = MapClasses([Coords, Names], mapper) // okay
    // const mapped: { x: number; y: number;  myName: string; }
    

    Now we should see what happens when there's an error:

    MapClasses([Names, Coords], mapper) // error
    // -----------------------> ~~~~~~
    // Type '[Names, Coords]' is not assignable to type '[Coords, Names]'.
    

    And yes, that's what we wanted to see.

    Playground link to code