DEV Community

loading...

Typing deep pick function in TypeScript

yossarian
Originally published at catchts.com Updated on ・6 min read

In this article I will try to implement something similar to lodash.get function.

This is what I want to implement:

// John
const result = deepPick({ user: { name: 'John' } }, 'user', 'name') 
Enter fullscreen mode Exit fullscreen mode

First of all, we should validate all arguments but first, because they should be in right order. Let's assume we have next object:

type Foo = {
    user: {
        description: {
            name: string;
            surname: string;
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Now, we need to generate array of all allowed keys.In order to do that, we need to iterate through every key and replace type property with array of keys.

Does it make sense for You?

type FirstAttempt<T> = {
    [P in keyof T]: [P]
}
Enter fullscreen mode Exit fullscreen mode

Above code does not make any sense. We need to iterate through every nested property, hence, we need to make it recursively. Let's try again. But now, we need call recursion only if property is not primitive.


type SecondAttempt<Obj> = {
    [Prop in keyof Obj]:
    Obj[Prop] extends PropertyKey
    ? [Prop]
    : SecondAttempt<Obj[Prop]>
}

// { name: ["name"]; surname: ["surname"]; }
type Result = SecondAttempt<Foo>['user']['description']

Enter fullscreen mode Exit fullscreen mode

It is looks better now, but we did not receive full path to name and surname. We only have an array of last non primitive property.

Seems, we need some cache.

type ThirdAttempt<Obj, Cache extends Array<PropertyKey> = []> = {
    [Prop in keyof Obj]: Obj[Prop] extends PropertyKey
    ? [...Cache, Prop]
    : ThirdAttempt<Obj[Prop], [...Cache, Prop]>
}

// {
//     name: ["user", "description", "name"];
//     surname: ["user", "description", "surname"];
// }
type Result = ThirdAttempt<Foo>['user']['description']
Enter fullscreen mode Exit fullscreen mode

Looks a way better now. But we need all possible values. User should be able to get not only primitive values. We should allow user to get ['user', 'description']

We can try to pass Cache as a union of previous and next type.

type FourthAttempt<Obj, Cache extends Array<PropertyKey> = []> = {
    [Prop in keyof Obj]: Obj[Prop] extends PropertyKey
    ? [...Cache, Prop]
    : FourthAttempt<Obj[Prop], Cache | [...Cache, Prop]>
}

type Result = FourthAttempt<Foo>['user']['description']
Enter fullscreen mode Exit fullscreen mode

Seems we are closer now. Pls keep in mind, we still need a union of arrays instead of some weird object. Btw, it is still does not meet our requirements

Let's move our condition statement one level up.


type FifthAttempt<Obj, Cache extends Array<PropertyKey> = []> =
    Obj extends PropertyKey
    ? Cache
    : {
        [Prop in keyof Obj]:
        FifthAttempt<Obj[Prop], Cache | [...Cache, Prop]>
    }

type Result = FifthAttempt<Foo>
Enter fullscreen mode Exit fullscreen mode

Assume we have our union of arrays in one place, as a value of deepest keys. How we can get it? I'd willing to bet that You are aware of famous utility type type Values<T>=T[keyof T]

type Values<Obj> = Obj[keyof Obj]

type SixthAttempt0<Obj, Cache extends Array<PropertyKey> = []> =
    Obj extends PropertyKey
    ? Cache
    : Values<{
        [Prop in keyof Obj]:
        SixthAttempt0<Obj[Prop], Cache | [...Cache, Prop]>
    }>

type Result = SixthAttempt0<Foo>
Enter fullscreen mode Exit fullscreen mode

We still have a logical error in our code. Let's fix it.

type FinalAttempt<Obj, Cache extends Array<PropertyKey> = []> =
    Obj extends PropertyKey
    ? Cache
    : {
        [Prop in keyof Obj]:
        | [...Cache, Prop]
        | FinalAttempt<Obj[Prop], [...Cache, Prop]>
    }[keyof Obj]

type Result = FinalAttempt<Foo>
Enter fullscreen mode Exit fullscreen mode

Finally we did it. Are You bored? Feel free to make a break.

So far we did only 50% of our work. Let's test it.

type FinalAttempt<Obj, Cache extends Array<PropertyKey> = []> =
    Obj extends PropertyKey ? Cache : {
        [Prop in keyof Obj]:
        | [...Cache, Prop]
        | FinalAttempt<Obj[Prop], [...Cache, Prop]>
    }[keyof Obj]

declare function deepPick<Obj,>(obj: Obj, ...keys: FinalAttempt<Obj>): void
declare var foo: Foo;

deepPick(foo, 'user'); // ok
deepPick(foo, 'user', 'description') // ok
deepPick(foo, 'description') // expected error
Enter fullscreen mode Exit fullscreen mode

What about our ReturnType?

Btw, if You will try to write implementation for this function, You will get an errors:

function deepPick<Obj >(obj: Obj, ...keys: FinalAttempt<Obj>){
    return keys.reduce((acc,elem)=>acc[elem], obj) // <-- errors
}
Enter fullscreen mode Exit fullscreen mode

In order to make it work, we should assure TS that keys has not any problems with infinity recursion and it is an array of strings. Further more, how would you type reducer predicate? Since, every iteration it returns different type.

We can type it in same way we typed union of keys (FinalAttempt), but this time let's make a union of values. It returns all combinations of Foo values.

type ValuesUnion<Obj, Cache = Obj> =
    Obj extends Primitives ? Obj : Values<{
        [Prop in keyof Obj]:
        | Cache | Obj[Prop]
        | ValuesUnion<Obj[Prop], Cache | Obj[Prop]>
    }>
Enter fullscreen mode Exit fullscreen mode

I also used here simple hasProperty typeguard.

const hasProperty = <Obj, Prop extends Primitives>(obj: Obj, prop: Prop)
    : obj is Obj & Record<Prop, any> =>
    Object.prototype.hasOwnProperty.call(obj, prop);

function deepPick<Obj, Keys extends FinalAttempt<Obj> & Array<string>>
    (obj: ValuesUnion<Obj>, ...keys: Keys) {
    return keys
        .reduce(
            (acc, elem) => hasProperty(acc, elem) ? acc[elem] : acc,
            obj
        )
}
Enter fullscreen mode Exit fullscreen mode

Let's write type for picking the object property. I decided to implement deep picking exactly how we did it in a function. I think it will help You to understand how does it work under the hood. Ok, ok, You cought me. I just don't know how to do it in other way :)

type Elem = string;

type Acc = Record<string, any>

// (acc, elem) => hasProperty(acc, elem) ? acc[elem] : acc
type Predicate<Accumulator extends Acc, El extends Elem> =
    El extends keyof Accumulator ? Accumulator[El] : Accumulator

type Reducer<
    Keys extends ReadonlyArray<Elem>,
    Accumulator extends Acc = {}
    > =
    /**
     *  If Keys is empty array, no need to call recursion, 
     *  just return Accumulator
     */
    Keys extends []
    ? Accumulator
    /**
     * If keys is one element array,
     * 
     */
    : Keys extends [infer H]
    ? H extends Elem
    /**
     * take this element and call predicate 
     */
    ? Predicate<Accumulator, H>
    : never
    /**
     * If Keys is an Array of more than one element
     */
    : Keys extends readonly [infer H, ...infer Tail]
    ? Tail extends ReadonlyArray<Elem>
    ? H extends Elem
    /**
     * Call recursion with Keys Tail
     * and call predicate with first element 
     */
    ? Reducer<Tail, Predicate<Accumulator, H>>
    : never
    : never
    : never;
Enter fullscreen mode Exit fullscreen mode

If it is hard to understand what I did here, no worries, it is hard for me either.

Here you have pure js analogy:

const reducer = (keys: string[], accumulator: Record<string, any> = {}) => {
    const predicate = (obj,prop)=>obj[prop]

    if (keys.length === 0) {
        return accumulator;
    }
    if (keys.length === 1) {
        const [head] = keys;
        return reducer([], predicate(accumulator, head))
    }

    if(keys.length>1){
        const [head, ...tail]=keys;
        return reducer(tail, predicate(accumulator, head))
    }
}
Enter fullscreen mode Exit fullscreen mode

Ok, we are done. Here is the full code:

type Foo = {
    user: {
        description: {
            name: string;
            surname: string;
        }
    }
}

declare var foo: Foo;

/**
 * Common utils
 */

type Primitives = string | number | symbol;

type Values<T> = T[keyof T]

type Elem = string;

type Acc = Record<string, any>

// (acc, elem) => hasProperty(acc, elem) ? acc[elem] : acc
type Predicate<Accumulator extends Acc, El extends Elem> =
    El extends keyof Accumulator ? Accumulator[El] : Accumulator

type Reducer<
    Keys extends ReadonlyArray<Elem>,
    Accumulator extends Acc = {}
    > =
    /**
     *  If Keys is empty array, no need to call recursion, 
     *  just return Accumulator
     */
    Keys extends []
    ? Accumulator
    /**
     * If keys is one element array,
     * 
     */
    : Keys extends [infer H]
    ? H extends Elem
    /**
     * take this element and call predicate 
     */
    ? Predicate<Accumulator, H>
    : never
    /**
     * If Keys is an Array of more than one element
     */
    : Keys extends readonly [infer H, ...infer Tail]
    ? Tail extends ReadonlyArray<Elem>
    ? H extends Elem
    /**
     * Call recursion with Keys Tail
     * and call predicate with first element 
     */
    ? Reducer<Tail, Predicate<Accumulator, H>>
    : never
    : never
    : never;

const hasProperty = <Obj, Prop extends Primitives>(obj: Obj, prop: Prop)
    : obj is Obj & Record<Prop, any> =>
    Object.prototype.hasOwnProperty.call(obj, prop);


/**
 * Fisrt approach
 * 
 */

type KeysUnion<T, Cache extends Array<Primitives> = []> =
    T extends Primitives ? Cache : {
        [P in keyof T]:
        | [...Cache, P]
        | KeysUnion<T[P], [...Cache, P]>
    }[keyof T]

type ValuesUnion<T, Cache = T> =
    T extends Primitives ? T : Values<{
        [P in keyof T]:
        | Cache | T[P]
        | ValuesUnion<T[P], Cache | T[P]>
    }>

function deepPickFinal<Obj, Keys extends KeysUnion<Obj> & ReadonlyArray<string>>
    (obj: ValuesUnion<Obj>, ...keys: Keys): Reducer<Keys, Obj>

function deepPickFinal<Obj, Keys extends KeysUnion<Obj> & Array<string>>
    (obj: ValuesUnion<Obj>, ...keys: Keys) {
    return keys
        .reduce(
            (acc, elem) => hasProperty(acc, elem) ? acc[elem] : acc,
            obj
        )
}

/**
 * Ok
 */
const result = deepPickFinal(foo, 'user') // ok
const result2 = deepPickFinal(foo, 'user', 'description') // ok
const result3 = deepPickFinal(foo, 'user', 'description', 'name') // ok
const result4 = deepPickFinal(foo, 'user', 'description', 'surname') // ok

/**
 * Expected errors
 */
const result5 = deepPickFinal(foo, 'surname')
const result6 = deepPickFinal(foo, 'description')
const result7 = deepPickFinal(foo)
Enter fullscreen mode Exit fullscreen mode

There is another approach to do it. It requires to use validation technique.

I know, naming is awful :) I don't know how to name it in more meaningful way.

type Foo = {
    user: {
        description: {
            name: string;
            surname: string;
        }
    }
}

declare var foo: Foo;

type Primitives = string | number | symbol;


type Util<Obj, Props extends ReadonlyArray<Primitives>> =
    Props extends []
    ? Obj
    : Props extends [infer First]
    ? First extends keyof Obj
    ? Obj[First]
    : never
    : Props extends [infer Fst, ...infer Tail]
    ? Fst extends keyof Obj
    ? Tail extends string[]
    ? Util<Obj[Fst], Tail>
    : never
    : never
    : never

// credits https://github.com/microsoft/TypeScript/issues/23182#issuecomment-379091887
type IsNeverType<T> = [T] extends [never] ? true : false;

type IsAllowed<T> = IsNeverType<T> extends true ? false : true;
type Validator<T extends boolean | string> = T extends true ? [] : [never]
type ValuesUnion<T, Cache = T> =
    T extends Primitives ? T : {
        [P in keyof T]:
        | Cache | T[P]
        | ValuesUnion<T[P], Cache | T[P]>
    }[keyof T]

const hasProperty = <Obj, Prop extends Primitives>(obj: Obj, prop: Prop)
    : obj is Obj & Record<Prop, any> =>
    Object.prototype.hasOwnProperty.call(obj, prop);


function pick<
    Obj,
    Prop extends string,
    Props extends ReadonlyArray<Prop>,
    Result extends Util<Obj, Props>>
    (
        obj: ValuesUnion<Obj>,
        props: [...Props],
        ..._: Validator<IsAllowed<Result>>
    ): Util<Obj, Props>;

function pick<
    Obj,
    Prop extends string,
    Props extends ReadonlyArray<Prop>,
    Result extends Util<Obj, Props>>(
        obj: ValuesUnion<Obj>,
        props: [...Props],
        ..._: Validator<IsAllowed<Result>>) {
    return props.reduce(
        (acc, prop) => hasProperty(acc, prop) ? acc[prop] : acc,
        obj
    )
}

/**
 * Ok
 */
const result8 = pick(foo, ['user', 'description']) // ok
const result9 = pick(foo, ['user', 'description', 'name']) // ok

/**
 * Expected errors
 */

const result10 = pick(foo, ['description']) // error
const result11 = pick(foo, ['name']) // ok
Enter fullscreen mode Exit fullscreen mode

That's all. Thanks you

Discussion (0)