Is Typescript 4.0+ capable of functions using mapped variadic tuple types?

882 views Asked by At

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]> }]
  >;
2

There are 2 answers

0
jcalz On BEST ANSWER

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.


Playground link to code

5
Linda Paiste On

Can you enter an arbitrary number of arguments of different types and get back an array of Observables with those types in the same order? No. You can't map a type based on the specific numeric keys.

Can you enter those same arbitrary arguments and get back an array of Observables with the union of those types? Yes.

type Unpack<T> = T extends (infer U)[] ? U : T;

const mapToObservable = <T extends any[]>(...things: T): Observable<Unpack<T>>[] => things.map((thing) => of(thing));

const mapped: Observable<string | number>[] = mapToObservable(1, 3, "2");

Playground Link

Edit: I spoke too soon and it actually is possible to get the return types in the same order, as explained in this answer.

This return type gives you the return that you want

type MapToMyInterface<T extends any[]> = any[] & {
  [K in keyof T]: {a: T[K]};
} & {length: T['length']};

But the type that is returned from array.map() is not assignable to it (for the reasons which @jcalz just explained), so you have to assert that your return type is correct using as.

const mapToMyInterface = <T extends any[]>(...things: T): MapToMyInterface<T> => things.map((thing) => ({ a: thing})) as MapToMyInterface<T>;

const mapped = mapToMyInterface(1, 3, "2");
const [one, three, two] = mapped; // gets each element correctly

Playground Link