typescripttypescript-genericstypingkeyof

How to properly type a table of tuples to array of objects utility function (like zip) avoiding merged union of all values in typescript 5.2.2


I'm close to nailing it, but can't find a way around the final TS2322: Type  TcolTuple[i]  is not assignable to type  string | number | symbol compiler error.

So, here is a utility function rowsToObjects() that quite a few people probably defined in their projects once or twice, it's somewhat similar to zip() in concept:

const objects = rowsToObjects(
    ['id', 'color' , 'shape'   , 'size'  , 'to' ] as const,  
    [  1n, 'red'   , 'circle'  , 'big'   , '0x0'] as const,
    [  2n, 'green' , 'square'  , 'small' , '0x0'] as const,
    [  3n, 'blue'  , 'triangle', 'small' , '0x0'] as const,
)

That outputs:

[
    {id: 1n, color: 'red', shape: 'circle', size: 'big', to: '0x0'},
    {id: 2n, color: 'green', shape: 'square', size: 'small', to: '0x0'},
    {id: 3n, color: 'blue', shape: 'triangle', size: 'small', to: '0x0'},
]

The actual implementation is obviously trivial, but typing it gives me some hard time:

export function rowsToObjects<
    Tobj extends { [i in keyof TcolTuple as TcolTuple[i]]: TvalTuple[i] },
    TcolTuple extends readonly string[],
    TvalTuple extends { [j in keyof TcolTuple]: unknown }
>(cols: TcolTuple, ...rows: TvalTuple[]): Tobj[];

Current code seems logical to me, but the compiler complains about the as TcolTuple[i] part:

TS2322: Type  TcolTuple[i]  is not assignable to type  string | number | symbol 
  Type  TcolTuple[keyof TcolTuple]  is not assignable to type  string | number | symbol 
    Type
    TcolTuple[string] | TcolTuple[number] | TcolTuple[symbol]
    is not assignable to type  string | number | symbol 
      Type  TcolTuple[string]  is not assignable to type  string | number | symbol 

Am I missing something obvious here? The typing is close to satisfactory, but without that as TcolTuple[i] it does not recognize which value belongs to which key and just unions them all.

enter image description here


Solution

  • I think the main problem you're having with

    { [I in keyof TcolTuple as TcolTuple[I]]: TvalTuple[I] }
    

    is that using key remapping prevents the mapped type from being homomorphic (see What does "homomorphic mapped type" mean?), so instead of mapping over just the numeric-like indices of the tuple like "0" | "1" | "2", you're mapping over all the indices, including number, a mixture of all the elements. And that gives you the union you're unhappy with.

    The easiest change here is to explicitly map over only the numeric-like indices, by intersecting keyof TcolTuple with the pattern template literal type `${number}` (as implemented in microsoft/TypeScript#40598. That removes anything that isn't a string version of a number. For example, "0" | "1" | "2" | number | "length" | "find" when intersected with `${number}`, gives you just "0" | "1" | "2".

    That more or less fixes it:

    declare function rowsToObjects<
      Tobj extends { [I in `${number}` & keyof TcolTuple as TcolTuple[I]]: TvalTuple[I] },
      TcolTuple extends readonly string[],
      TvalTuple extends { [J in keyof TcolTuple]: unknown }
    >(cols: TcolTuple, ...rows: TvalTuple[]): Tobj[];
    
    const objects = rowsToObjects(
      ['id', 'color', 'shape', 'size', 'to'] as const,
      [1n, 'red', 'circle', 'big', '0x0'] as const,
      [2n, 'green', 'square', 'small', '0x0'] as const,
      [3n, 'blue', 'triangle', 'small', '0x0'] as const,
    )
    /* const objects: {
        id: 1n | 2n | 3n;
        color: "red" | "green" | "blue";
        shape: "circle" | "square" | "triangle";
        size: "big" | "small";
        to: "0x0";
    }[] */
    

    Personally, if I were writing this for myself, I would:

    • use const type parameters instead of requiring callers use const assertions;
    • maintain the tuple-type of the inputs so that if the input array is strongly ordered then so is the output (e.g., rowsToObjects(["a"],[0],[1]) should return [{a: 0}, {a: 1}] and not {a: 0 | 1}[];
    • remove the extra generic type parameters and just compute the output inline instead of relying on default type arguments;
    • use uppercase letters for mapped type parameters like I and J instead of i and j, keeping to the naming convention to distinguish types from variables (in {[P in K]: F<P>} P is a type parameter, not a variable name, so p could be confusing).

    None of these are of vital importance, but it gives the output

    declare function rowsToObjects<
      const K extends readonly PropertyKey[],
      const V extends readonly Record<keyof K, unknown>[]
    >(
      cols: K, ...rows: V
    ): { [I in keyof V]:
        { [J in `${number}` & keyof K as K[J]]:
          V[I][J]
        }
      };
    
    const objects = rowsToObjects(
      ['id', 'color', 'shape', 'size', 'to'],
      [1n, 'red', 'circle', 'big', '0x0'],
      [2n, 'green', 'square', 'small', '0x0'],
      [3n, 'blue', 'triangle', 'small', '0x0'],
    )
    /* const objects: readonly [{
        id: 1n;
        color: "red";
        shape: "circle";
        size: "big";
        to: "0x0";
    }, {
        id: 2n;
        color: "green";
        shape: "square";
        size: "small";
        to: "0x0";
    }, {
        id: 3n;
        color: "blue";
        shape: "triangle";
        size: "small";
        to: "0x0";
    }] */
    

    Playground link to code