Home > Software engineering >  Mapped array of tuples to object doesn't produce correct values
Mapped array of tuples to object doesn't produce correct values

Time:10-12

A friend of mine was trying to write a type which did a conversion similar to the runtime behavior of Object.fromEntries; that is convert a type for an array of two-element tuples (say [["a", number], ["b", string]]) to a type for an object with those types as key-value pairs (in this case, {a: number; b: string}).

His first attempt to do this was the below, which didn't work:

type ObjectKey = string | number;

type EntryKey<T> = T extends [infer K, unknown]
  ? K extends ObjectKey
    ? K
    : never
  : never;

type EntryValue<T> = T extends [ObjectKey, infer V] ? V : never;

type ObjFromEntriesBad<Entries extends [ObjectKey, unknown][]> = {
  [Index in keyof Entries as EntryKey<Entries[Index]>]: EntryValue<
    Entries[Index]
  >;
};

// Incorrect: is `{a: string | number; b: string | number;}
type Test1 = ObjFromEntriesBad<[["a", number], ["b", string]]>

I wrote a simplified version which I thought should work, but it failed with compiler errors:

// Error: Type '0' cannot be used to index type 'Entries[Index]'
type ObjFromEntriesBad2<Entries extends [ObjectKey, unknown][]> = {
  [Index in keyof Entries as Entries[Index][0]]: Entries[Index][1]
};

I finally came up with a solution that seemed to work:

type ObjFromEntriesGood<Entries extends [ObjectKey, unknown][]> = {
  [Tup in Entries[number] as Tup[0]]: Tup[1]
};

My questions are then: why didn't the first solution work? Why does the second solution cause compiler errors? And finally, in the working solution we iterate of Entries[number], but is there a way to create a correct type while still iterating over keyof Entries in the mapped type?

CodePudding user response:

The original versions don't work because currently (as of TS4.4 anyway) there is no support for mapping array and tuple types while also remapping the keys. (See microsoft/TypeScript#405886 for a suggestion to, among other things, change that.) As soon as you try, the mapped object gets all the keys of the array, including things like "push" and "pop" and number:

type Foo<T> = { [K in keyof T as Extract<K, string | number>]: T[K] }

type Okay = Foo<{ a: 1, b: 2, c: 3 }> // same
type StillOkay = Foo<{ 0: 1, 1: 2, 2: 3 }> // same
type Oops = Foo<[1, 2, 3]>
/* type Oops = {
    [x: number]: 1 | 2 | 3;
    0: 1;
    1: 2;
    2: 3;
    length: 3;
    toString: () => string;
    toLocaleString: () => string;
    pop: () => 1 | 2 | 3 | undefined;
    push: (...items: (1 | 2 | 3)[]) => number;
    concat: {
        (...items: ConcatArray<1 | 2 | 3>[]): (1 | ... 1 more ... | 3)[];
        (...items: (1 | ... 2 more ... | ConcatArray<...>)[]): (1 | ... 1 more ... | 3)[];
    };
    ... 23 more ...;
    includes: (searchElement: 1 | ... 1 more ... | 3, fromIndex?: number | undefined) => boolean;
} */

So in ObjFromEntriesBad:

type ObjFromEntriesBad<E extends [ObjectKey, unknown][]> = {
  [I in keyof E as EntryKey<E[I]>]: EntryValue<E[I]>;
};

You'll get number as one of the I in keyof E, and EntryKey<E[I]> is the full union of all the keys, and EntryValue<E[I]> a full union of the values, and you lose the correlation you care about.

And in ObjFromEntriesBad2:

type ObjFromEntriesBad2<E extends [ObjectKey, unknown][]> = {
  [I in keyof E as E[I][0]]: E[I][1]
};

this problem persists. There's no 0 or 1 indices in E[I] when I is something like "push", so the error is correct. A lot of times the compiler couldn't verify that such indices were safe even if they were, so you would probably end up falling back to the EntryKey and EntryValue solution which uses conditional type inference via infer to sidestep such issues.


For your third question, you can't iterate directly over keyof E, that's the whole problem. You could do something similar, though, like use the Exclude<T, U> utility type to explicitly filter out all the arraylike properties:

type ObjFromEntries3<E extends [ObjectKey, unknown][]> = {
  [I in Exclude<keyof E, keyof any[]> as EntryKey<E[I]>]: EntryValue<E[I]>;
};

And verify that it actually works:

type Test3 = ObjFromEntries3<[["a", number], ["b", string]]>
/* type Test3 = {
    a: number;
    b: string;
} */

Playground link to code

  • Related