there is a problem I've been struggling to solve for quite a time.
I have a function, inside which I put two arguments:
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.
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.