As an example, say I have a simple function which maps a variadic number of things to an array of objects like { a: value }
.
const mapToMyInterface = (...things) => things.map((thing) => ({ a: thing}));
Typescript in not yet able to infer a strong type for the result of this function yet:
const mapToMyInterface = mapToInterface(1, 3, '2'); // inferred type{ a: any }[]
First, I define a type that describes an array mapped to observables:
type MapToMyInterface<T extends any[]> = {
[K in keyof T]: T[K] extends Array<infer U> ? { a: U } : { a: T[K] }
}
Now I update my function:
const mapToMyInterface = <T extends any[]>(...things: T): MapToMyInterface<T> => things.map((thing) => ({ a: thing}));
So far, Typescript is not happy. The return expression of the function is highlighted with the error "TS2322: Type '{ a: any; }[]' is not assignable to type 'MapToMyInterface'"
Obviously, the parameter thing
needs to be explicitly typed in the mapping function. But I don't know of a way to say "the nth type", which is what I need.
That is to say, neither marking thing
as a T[number]
or even doing the following works:
const mapToMyInterface = <T extends any[], K = keyof T>(...things: T): MapToMyInterface<T> => things.map((thing: T[K]) => of(thing));
Is is possible for this to work in Typescript?
EDIT after @jcalz's answer: For posterity, I wanted to post the original motivation for my question, and the solution I was able to get from @jcalz's answer.
I was trying to wrap an RxJs operator, withLatestFrom
, to lazily evaluate the observables passed into it (useful when you may be passing in an the result of a function that starts an ongoing subscription somewhere, like store.select
does in NgRx).
I was able to successfully assert the return value like so:
export const lazyWithLatestFrom = <T extends Observable<unknown>[], V>(o: () => [...T]) =>
concatMap(value => of(value).pipe(withLatestFrom(...o()))) as OperatorFunction<
V,
[V, ...{ [i in keyof T]: TypeOfObservable<T[i]> }]
>;
Let's say you have a generic function wrap()
which takes a value of type T
and returns a value of type {a: T}
, as in your example:
function wrap<T>(x: T) {
return ({ a: x });
}
If you just make a function which takes an array things
and calls things.map(wrap)
, you'll get a weakly typed function, as you noticed:
const mapWrapWeaklyTyped = <T extends any[]>(...things: T) => things.map(wrap);
// const mapWrapWeaklyTyped: <T extends any[]>(...things: T) => {a: any}[]
This completely forgets about the individual types that went in and their order, and you just an array of {a: any}
. It's true enough, but not very useful:
const weak = mapWrapWeaklyTyped(1, "two", new Date());
try {
weak[2].a.toUpperCase(); // no error at compile time, but breaks at runtime
} catch (e) {
console.log(e); // weak[2].prop.toUpperCase is not a function
}
Darn, the compiler didn't catch the fact that 2
refers to the third element of the array which is a wrapped Date
and not a wrapped string
. I had to wait until runtime to see the problem.
If you look at the standard TypeScript library's typing for Array.prototype.map()
, you'll see why this happens:
interface Array<T> {
map<U>(callbackfn: (value: T, index: number, array: T[]) => U, thisArg?: any): U[];
}
When you call things.map(wrap)
, the compiler infers a single U
type, which is unfortunately going to be {a: any}
, because if things
is of type T extends any[]
, all the compiler knows about the elements of things
is that they are assignable to any
.
There's really no good general typing you could give to Array.prototype.map()
that will handle the case where the callbackfn
argument does different things to different types of input. That would require higher kinded types like type constructors, which TypeScript doesn't currently support directly (see microsoft/TypeScript#1213 for a relevant feature request).
But in the case where you have a specific generic type for your callbackfn
, (e.g., (x: T) => {a: T}
), you can manually describe the specific type transformation on a tuple or array using mapped array/tuple types.
Here it is:
const mapWrapStronglyTyped = <T extends any[]>(...things: T) => things.map(wrap) as
{ [K in keyof T]: { a: T[K] } };
// const mapWrapStronglyTyped:
// <T extends any[]>(...things: T) => { [K in keyof T]: {a: T[K]; }; }
What we're doing here is just iterating over each (numeric) index K
of the T
array, and taking the T[K]
element at that index and mapping it to {a: T[K] }
.
Note that because the standard library's typing of map()
does not anticipate this particular generic mapping function, you have to use a type assertion to have it type check. If you're only concerned about the compiler's inability to verify this without a type assertion, this is really about the best you can do without higher kinded types in TypeScript.
You can test it out on the same example as before:
const strong = mapWrapStronglyTyped(1, "two", new Date());
try {
strong[2].a.toUpperCase(); // compiler error, Property 'toUpperCase' does not exist on type 'Date'
} catch (e) {
console.log(e); // strong[2].prop.toUpperCase is not a function
}
// oops, I meant to do this instead!
console.log(strong[1].a.toUpperCase()); // TWO
Now the compiler catches the mistake, and tells me that Date
objects don't have a toUpperCase
method. Hooray!
Your version of the mapping,
type MapToMyInterface<T extends any[]> = {
[K in keyof T]: T[K] extends Array<infer U> ? { a: U } : { a: T[K] }
}
is a little weird because you're doing the mapping twice; unless you're passing in arrays of arrays, there's no reason to check T[K]
for whether or not it's an array itself. T
is the array, and K
is the index of it. So I'd say just return {a: T[K]}
unless I'm missing something important.