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

69 views Asked by At

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

1

There are 1 answers

0
jcalz On BEST ANSWER

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