Search code examples
node.jsarraystypescriptdestructuring

Correct and unify type of destructured list


I want to operate with arrays of arrays of values but I cannot find the right type when destructuring the lists.
The values of my arrays are not of the same type. I am neither looking into passing an array as a parameter. I am trying to destructure arrays, not objects.


I have the following non compilling snippet of ts:

const bar = (n: number, s: string, p: string): string => n === 1 ? s : p;

const f = (args: [number, string, string]): string => bar(...args)

const r = [
  [1, 'a', 'b'],
  [2, 'c', 'd'],
].map(f);

which throws the error:

Argument of type '(args: [number, string, string]) => string' is not assignable to parameter of type '(value: (string | number)[], index: number, array: (string | number)[][]) => string'.
  Types of parameters 'args' and 'value' are incompatible.
    Type '(string | number)[]' is not assignable to type '[number, string, string]'.
      Target requires 3 element(s) but source may have fewer.

with Ts v4.3.5 the offiial editor. The resulting js does work with node

➜  ~ node 
Welcome to Node.js v14.17.0.
Type ".help" for more information.
> const bar = (n, s, p) => n === 1 ? s : p;
undefined
> const f = (args) => bar(...args);
undefined
> const r = [
...     [1, 'a', 'b'],
...     [2, 'c', 'd'],
... ].map(f);
undefined
> r
[ 'a', 'd' ]

Is it possible to generalize this?


Solution

  • Background

    The problem is that TypeScript unfortunately will not infer the tuple types for the input data that you expect it to infer.

    Consider the following types:

    const t = [1, 'a', 'b']
    // type of t: (string | number)[]
    
    const t2 = [1, 'a', 'b'] as const
    // type of t2: readonly [1, 'a', 'b']
    

    The first assignment will infer an array of the union of the given element types. In the second assignment we use the as const addition to force the compiler to infer the most concrete type. As you see, it actually now infers literal types (e.g. 'a' instead of string).

    This may also not always be what we want. You can of course just cast the elements to the correct types. However, I myself often use a helper function that does nothing except infer the types I most often want:

    function tuple<T extends unknown[]>(...tup: T): T {
      return tup;
    }
    
    const t3 = tuple(1, 'a', 'b')
    // type of t3: [number, string, string]
    

    Solution 1

    Let's apply this knowledge to your example:

    const input = [
      [1, 'a', 'b'],
      [2, 'c', 'd'],
    ]
    // type of input: (string | number)[][]
    
    const input2 = [
      tuple(1, 'a', 'b'),
      tuple(2, 'c', 'd')
    ]
    // type of input2: [number, string, string][]
    

    The last type is exactly what you need for your input type of f.

    Combining everything, we get:

    const bar = (n: number, s: string, p: string): string => n === 1 ? s : p;
    
    const f = (args: [number, string, string]): string => bar(...args)
    
    function tuple<T extends unknown[]>(...tup: T): T {
      return tup;
    }
    
    const r = [
      tuple(1, 'a', 'b'),
      tuple(2, 'c', 'd'),
    ].map(f);
    // type of r: string[]
    

    Solution 2

    If you don't want to refine the input type, you can also create a helper for the .map function that will infer the correct types:

    const bar = (n: number, s: string, p: string): string => (n === 1 ? s : p);
    
    const f = (args: [number, string, string]): string => bar(...args);
    
    function mapValues<T, R>(values: T[], f: (value: T) => R): R[] {
      return values.map(f);
    }
    
    const r = mapValues(
      [
        [1, "a", "b"],
        [2, "c", "d"]
      ],
      f
    );
    

    The result should be the same.

    Solution 3

    If you really do not like or cannot use helper functions, the easiest solution I can come up with is using as const. You will need to slightly change the type of f because the arrays will become readonly:

    const bar = (n: number, s: string, p: string): string => (n === 1 ? s : p);
    
    const f = (args: readonly [number, string, string]): string => bar(...args);
    
    const r = ([
      [1, "a", "b"],
      [2, "c", "d"] 
    ] as const).map(f);