Map array of functions preserving parameter types. Can variadic tuple types from TypeScript v4 improve this?

Viewed 581

Initially, I wanted to type redux' mapDispatchToProps-like function and got stuck with handling an array of functions (action creators) as an argument. There is a hacky but working example. I wonder if it can be improved.

The problem is that return type does not preserve types per item, it gets generalized to a union.

Update: the problem is in preserving parameter types per function in the resulting array.

In short, code below should be with no errors:

type F = (...x: any[]) => any
type Wrap<T extends F> = (...x: Parameters<T>) => void

const wrap = (fn: any) => (...a: any) => {fn(...a)}

// currently working solution
// the problem is K being too wide (number | string | symbol) for array index thus silenced
// // @ts-expect-error
// function main<Fs extends readonly F[]>(fs: Fs): {[K in keyof Fs]: Wrap<Fs[K]>}

// TODO: desired solution but not finished: every item in `fs` should be wrapped with `Wrap`
function main<Fs extends readonly F[]>(fs: Fs): [...Fs]

function main(fs: any) {return fs.map(wrap)}

const n = (x: number) => x
const s = (x: string) => x
const fs = main([n, s] as const)

// TEST PARAMETERS TYPES
fs[0](1)
fs[1]('1')
// @ts-expect-error
fs[0]('1')
// @ts-expect-error
fs[1](1)

// TEST RETURN TYPES
const _1: void = fs[0](1)
const _2: void = fs[1]('1')
// @ts-expect-error
const _3: number = fs[0](1)
// @ts-expect-error
const _4: string = fs[1]('1')

TypeScript playground

P.S: there is an open (as for 25-aug-2020) github issue related to problem of my solution #1, so it is not about variadic tuple types but about keyof ArrayType being too wide for an array index type

3 Answers

TypeScript 4.0 can indeed represent variadic tuple types so you can annotate your main function to indicate what it does; maybe something like this:

const main = <F extends any[]>(f: F): [...F] => [...f];

(If you want to make it F extends Function[] or some specific subtype of function that should work too, but for this example I'm just using any instead of a function type.) Now your example above behaves as you want:

const n = (x: number) => x
const s = (x: string) => x
const [n2, s2] = main([n, s])
n2(1) // okay
s2('1') // okay
n2('1') // error
s2(1) // error

Playground link.

But you could get something like this behavior for TypeScript 3.9 and below by asserting that the return type is the same as the input type, which is close enough to what you're doing and behaves the same (the difference between [...F] and F is small enough not to worry about in most cases):

const main = <F extends any[]>(f: F) => [...f] as F;

Playground link


Note that sometimes the compiler doesn't infer tuple types where you want it to without hints; by writing const [n2, s2] = main([n, s]); you're hinting that you want tuples, so you get tuples.

Otherwise, const n2s2 = main([n, s]) would just be a unordered array type whose elements are of a union type. You could give a hint in the type signature of main by making the F type parameter include a tuple type in its constraint:

const main = <F extends any[] | []>(f: F) => [...f] as F;

(see microsoft/TypeScript#27179 for this idea) which makes this work:

const n2s2 = main([n, s]);
// const n2s2: [(x: number) => number, (x: string) => string]

Playground link


So, some combination of those techniques should hopefully overcome the problem you're having.

You need to let typescript infer the tuple type. Reading up on Variadric Tuples, it looks you need to constrain the generic type parameter to type any[] and then return a spread of that generic parameter:

type Main = <T extends any[]>(fns: T) => [...T]

const main: Main = (fns) => [...fns]

Now the rest works just like you expect.

Playground

You actually don't need TS4 variadic tuples at all. just mapped (tuple) types. The following works in TypeScript 3.5 (though // @ts-expect-error is a 3.9 feature):

function main<Fs extends readonly F[]>(fs: Fs):
    { [K in keyof Fs]: Fs[K] extends F ? Wrap<Fs[K]> : never }

Playground Link


Update: On array (rather than tuple) types, the above will return an array type where the individual indices are no longer known. Once the tuple indices are lost there is no way to recover this information. You can use as const to prevent the initial loss of the tuple information:

const test3 = [
    console.log,
    (x: number) => x,
] as const;
const fs3 = main(test3);
// const fs3: readonly [Wrap<(...data: any[]) => void>, Wrap<(x: number) => number>]

If you want to be sure you aren't losing the indices, it is possible to reject array types and only allow tuples using a bit of a hack:

function main<Fs extends readonly F[]>(
    fs: number extends Fs['length'] ? never : Fs):
    { [K in keyof Fs]: Fs[K] extends F ? Wrap<Fs[K]> : never }

(edit: K in Exclude<keyof Fs, keyof []> -> K in keyof Fs like above, not sure why I had that)
Like above, all of this works in TS3.

Playground Link

Related