I created the following util function within my codebase:
const pick = <T extends object, P extends keyof T, R = Pick<T,P>>(
obj: T,
keys: P[]
): R => {
if (!obj) return {} as R
return keys.reduce((acc, key) => {
return {...acc, [key]:obj[key] };
}, {} as R)
};
The function works fine and TS infers the correct return type. Only issue is the keys parameter, I want to constrain it based on the previous chosen keys.
Example:
const obj = {name: 'John Doe', age: '33', city: 'NYC'}
// When typing the keys in the keys array param, it infers the keys correctly
const a = pick(obj, ['name', 'age'])
// BUT, this is also possible, and TS doesnt complain
const b = pick(obj, ['name', 'age', 'age'])
// ALSO, when I have already entered for example 'name', I want intellisense only to show 'age' and 'city' as possible options, currently it still shows all keys.
I tried many things (even currying the function), but with no success, its proven to be a tough TS puzzle. I hope I can get some help!
CodePudding user response:
TypeScript doesn't natively support the concept of a "unique" array where no elements are duplicated. So there's no simple way to write keys: UniqueArray<P>
.
Instead you could try to build a complicated generic and conditional type that acts like a constraint on the tuple type you pass in for keys
, where each element type excludes all previous element types. So if you pass in an array of type K extends Array<keyof T>
, the compiler will make sure that K
is assignable to ExcludeArray<keyof T, K>
and complain if it doesn't.
It could look like this:
type ExcludeArray<T, U extends any[], A extends any[] = []> =
number extends U['length'] ? [...T[]] : (
U extends [infer F, ...infer R] ?
ExcludeArray<Exclude<T, F>, R, [...A, F extends T ? F : T]> : A
);
function pick<T extends object, K extends Array<keyof T>>(
obj: T,
keys: [...K] extends ExcludeArray<keyof T, K> ? K : ExcludeArray<keyof T, K>
): Pick<T, K[number]>;
function pick(obj: any, keys: PropertyKey[]) {
if (!obj) return {}
return keys.reduce((acc, key) => {
return { ...acc, [key]: obj[key] };
}, {})
};
The ExcludeArray<T, U>
type takes a type T
and a tuple type U
and walks through U
building a version of it where each element excludes all previous elements:
type Example1 = ExcludeArray<1 | 2 | 3 | 4, [1, 2, 3, 4]>
// type Example1 = [1, 2, 3, 4]
type Example2 = ExcludeArray<1 | 2 | 3 | 4, [1, 2, 2, 4]>
// type Example2 = [1, 2, 3 | 4, 4]
So Example1
is the same as the U
passed in, because there are no duplicates. But Example2
shows that the third element has been changed from 2
(a duplicate) to 3 | 4
(the allowable values at that point).
Note that in a perfect world we could have written pick()
like
function pick<T extends object, K extends ExcludeArray<keyof T, K>>(
obj: T, keys: K
): Pick<T, K[number]> {...}
but the constraint K extends ExcludeArray<keyof T, K>>
is illegally circular. Instead we need to jump through hoops where K extends Array<keyof T>
at declaration and then we convince the compiler to treat K
as if it were so constrained at the use site, hence keys: [...K] extends ExcludeArray<keyof T, K> ? K : ExcludeArray<keyof T, K>
.
Anyway, let's test it:
const obj = { name: 'John Doe', age: '33', city: 'NYC' }
const a = pick(obj, ['name', 'age']); // okay
const b = pick(obj, ['name', 'age', 'age']); // error!
// -------------------------------> ~~~~~
// Type '"age"' is not assignable to type '"city"'
const c = pick(obj, ["name", "age", "ss"])
// -------------------------------> ~~~~
// Type '"ss"' is not assignable to type '"name" | "age" | "city"'
This behaves as desired.
Unfortunately, as you note, you don't get the autocomplete prompting behavior from IntelliSense. When you are in the middle of calling pick()
, the prompted values will be the full set of keys, even the ones you've already used:
const d = pick(obj, ["age", "