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