Question

Typescript generic object type from two arrays

Given two arrays, keyArr and valArr, how can I define a Typescript generic that would infer an object type <O> with keys from keyArr and values from valArr (as seen in this answer or in lodash _.zipObject).

function objectBuilder<
    K extends string[],
    V extends any[],
    O extends object = ???
>(keyArr: K, valArr: V): O {
    const result: O = {};
    keyArr.forEach((key, i) => result[key] = valArr[i]);
    return result;
}
objectBuilder(['foo', 'bar', 'baz'], [11, 22, 33]);
// Type O = { foo: number; bar: number; baz: number; };

For a mixed valArr, either of the following type inference is acceptable:

objectBuilder(['foo', 'bar', 'baz'], [11, "22", false]);

// Preferred:
// Type O = { foo: number; bar: string; baz: boolean; };

// Also acceptable:
// Type O = { 
//     foo: number | string | boolean;
//     bar: number | string | boolean;
//     baz: number | string | boolean;
// };

Also, can we define the type such that keyArr and valArr must be of the same length and valArr must be composed of unique keys?

objectBuilder(['foo', 'bar', 'baz'], [11, 22, 33]); // OK
objectBuilder(['foo', 'bar', 'baz'], [11, 22]);     // Error
objectBuilder(['foo', 'bar'], [11, 22, 33]);        // Error
objectBuilder(['foo', 'bar', 'baz'], [11, 11, 11]); // OK
objectBuilder(['foo', 'bar', 'foo'], [11, 22, 33]); // Error
 3  79  3
1 Jan 1970

Solution

 2

To achieve this, you need to use tuples and mapped types together:

// Creates tuples of corresponding elements from K and V arrays
type Zip<K extends readonly string[], V extends readonly any[]> = {
  [I in keyof K]: I extends keyof V ? [K[I], V[I]] : never
};

// Converts a tuple array to an object type
type ObjectType<T extends readonly [string, any][]> = {
  [K in T[number][0]]: Extract<T[number], [K, any]>[1]
};

function objectBuilder<
  K extends readonly string[],
  V extends readonly any[]
>(keyArr: K & { length: V['length'] }, valArr: V): ObjectType<Zip<K, V>> {
  const result = {} as any;
  keyArr.forEach((key, i) => {
    result[key] = valArr[i];
  });
  return result;
}

// To convert an array to a tuple we use "as const"
// tuples preserve their length
// which is crucial to force both tuples to be the same length

// { foo: number; bar: number; baz: number; }
const obj1 = objectBuilder(['foo', 'bar', 'baz'] as const, [11, 22, 33] as const);

// { foo: number; bar: string; baz: boolean; }
const obj2 = objectBuilder(['foo', 'bar', 'baz'] as const, [11, "22", false] as const); 

To constrain keyArr to be an array of strings with the same length as valArr we can use the following type(K is a type for keyArr and V is a type for valArr:

keyArr: K & { length: V['length'] }
2024-06-29
Artur Minin

Solution

 1

My inclination would be to give objectBuilder() the following call signature:

function objectBuilder<
    const K extends readonly PropertyKey[],
    V extends { [I in keyof K]: unknown },
>(
    keyArr: K & NoDuplicates<K>,
    valArr: readonly [...V]
): { [I in `${number}` & keyof K as K[I]]: V[I] } {
    const result: any = {};
    keyArr.forEach((key, i) => result[key] = valArr[i]);
    return result;
}

The function is generic in K, the type of the keyArr input, and of V, the type of the valArr input. Both of these parameters are intended to be instantiated with tuple types, so that TypeScript knows their order and thus which key goes with which value. Additionally, the elements of K should be literal types which keep track of the exact string literals passed in; we want "foo" and not string. (For V it's not important that the types be literal types.)

For K, since we want a tuple of literal property-key types, I've made it a const type parameter, which gives the compiler a hint to interpret an array literal of strings as such a tuple type.

We want valArr to be the same length as keyArr, so I've used the mapped tuple type {[I in keyof K]: unknown} to constrain V so its the same length as K. And instead of making V a const type parameter, I've made valArray a variadic tuple of type readonly [...V] which gives the compiler a hint to interpret an array literal as a tuple type, but not necessarily the literal types of their elements. So if valArr is [1, "two", true] then V should be [number, string, boolean] and not [1, "two", true].

The important part here is the return type, a key-remapped mapped type. I iterate over the numeric like indices of K (this is `${number}` & keyof K), which look like "0" | "1" | "2" for a three-element tuple. For each such key I, the return type has a property with key K[I] and value V[I].

Finally, keyArr is both of type K and of type NoDuplicates<K>, a utility type that transforms K into another tuple where any unique elements map to unknown and duplicates map to never. So ["foo", "bar", "baz"] maps to [unknown, unknown, unknown] (and note that the former is assignable to the latter), while ["foo", "bar", "foo"] maps to [never, unknown, never] (and note that the former is not assignable to the latter). This will cause errors to appear on duplicate elements.

Here's how NoDuplicates is defined:

type NoDuplicates<T extends readonly any[]> =
    { [I in keyof T]:
        { [J in keyof T]:
            I extends J ? never :
            T[I] extends T[J] ? unknown :
            never
        }[number] extends never ? unknown : never
    }

Essentially it's a doubly-mapped tuple type, where for each index I of the input, we then check all the other elements of index J. If we find a distinct pair of I and J such that T[I] extends T[J], then that element turns the whole inner expression to unknown. Otherwise it is never. And then that conditional type extends never ? unknown : never flips the result, so a duplicate in T maps to never while a unique element maps to unknown. This is tricky to explain, but if you walk through how it behaves for, say, ["foo", "bar", "foo"] it should hopefully make sense. I won't belabor the point further.


Okay, let's test it:

const x = objectBuilder(['foo', 'bar', 'baz'], [11, 22, 33]);
//    ^? const x: { foo: number; bar: number; baz: number; }

const y = objectBuilder(['foo', 'bar', 'baz'], [11, "22", false]);
//    ^? const y: { foo: number; bar: string; baz: boolean; }

objectBuilder(['foo', 'bar', 'baz'], [11, 22, 33]); // OK
objectBuilder(['foo', 'bar', 'baz'], [11, 22]);     // Error
objectBuilder(['foo', 'bar'], [11, 22, 33]);        // Error
objectBuilder(['foo', 'bar', 'baz'], [11, 11, 11]); // OK
objectBuilder(['foo', 'bar', 'foo'], [11, 22, 33]); // Error

Looks good! You get the output types you expected, and all the errors you want for mismatched lengths or duplicate keys.

Playground link to code

2024-06-29
jcalz