Home > database >  Constrain 'pickable' attributes after picks in pick function (TypeScript)
Constrain 'pickable' attributes after picks in pick function (TypeScript)

Time:10-06

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", "           
  • Related