1

I have this sloppy, hacked together function in JavaScript that allows you to pick properties from an object using dot notation:

const pickObjProps = (obj,paths)=>{
    let newObj = {}

    paths.forEach((path)=>{
        const value = path.split('.').reduce((prev,curr)=>{
            return prev ? prev[curr] : null;
        }
        , obj || self);
        function buildObj(key, value) {
            var object
            var result = object = {};
            var arr = key.split('.');
            for (var i = 0; i < arr.length - 1; i++) {
                object = object[arr[i]] = {};
            }
            object[arr[arr.length - 1]] = value;
            return result;
        }

        newObj = Object.assign(newObj, {
            ...buildObj(path, value)
        })
    }
    )
    return newObj

}

const obj = {
    primaryEmail: "mocha.banjo@somecompany.com",
    suspended: false,
    id: 'aiojefoij23498sdofnsfsdfoij',
    customSchemas: {
        Roster: {
            prop1: 'val1',
            prop2: 'val2',
            prop3: 'val3'
        }
    },
    some: {
        deeply: {
            nested: {
                value: 2345945
            }
        }
    },
    names: {
        givenName: 'Mocha',
        familyName: 'Banjo',
        fullName: 'Mocha Banjo'
    },
    phones: [{
        type: 'primary',
        value: '+1 (000) 000-0000'
    }]

}

const result = pickObjProps(obj, ['primaryEmail', 'customSchemas.Roster', 'some.deeply.nested',])

console.log(result)

The function works as I intend it to. However, I want to type the function in TypeScript and am having a hell of a time.

I stumbled across another post which gave me some insight on how to possibly type it:

type PickByDotNotation<TObject, TPath extends string> = 
    TPath extends `${infer TKey extends keyof TObject & string}.${infer TRest}` ?
        PickByDotNotation<TObject[TKey], TRest> :
    TPath extends keyof TObject ?
        TObject[TPath] :
        never

In trying to type the function, I am trying to create a new type called PickManyByDotNotation that takes two generic arguments:

  • An Object
  • A array of strings

This is as far as I have gotten:

type PickManyByDotNotation<TObject, TPaths extends string[]> = TPaths extends [
    infer TKey extends string,
    infer TRest extends string[],
]
    ? PickManyByDotNotation<PickByDotNotation<TObject, TKey>, TRest>
    : TPaths extends string
    ? PickByDotNotation<TObject, TPaths>
    : never

type PickByDotNotation<TObject, TPath extends string> =
    // Constraining TKey so we don't need to check if its keyof TObject
    TPath extends `${infer TKey extends keyof TObject & string}.${infer TRest}`
    ? PickByDotNotation<TObject[TKey], TRest>
    : TPath extends keyof TObject
    ? TObject[TPath]
    : never

The idea would be to use the type as such:

interface Test {
   customer: {
      email: string;
      name: string;
      phone: string;
      id: string
   };
};

type PickMany = PickManyByDotNotation<Test, ['customer.email', 'custom.name']>

// which would theoretically return something like:
//
// customer: {
//   email: string
//   name: string
// }

I am pulling my hair out at this point, and am actually very embarrassed to be posting.

If you could help me finish the type PickManyByDotNotation and or possibly give me some insight on how to property type the function, I would be more than grateful.

robquinn
  • 89
  • 1
  • 9
  • The question you linked asks about `Pick`ing but the answer from there is *indexing*, not picking. (e.g., `Pick<{a: string, b: number}, "a">` is `{a: string}`, but the answer there just produces `string`). So unfortunately it doesn't do what you want and you might want to remove references to it since it's mostly kind of distracting. Not sure why the other answer is like that though. – jcalz May 13 '23 at 20:50
  • Does [this approach](https://tsplay.dev/we8rdW) work for you? If yes, I'll write an answer explaining; If not, what am I missing? – wonderflame May 13 '23 at 21:00
  • I came up with [this approach](https://tsplay.dev/WGdR0w) which I could write up as an answer if it meets your needs. This kind of deeply nested type manipulation often has all sorts of bizarre edge cases so please test thoroughly before answering yes or no. And if that doesn't work because of some edge case please [edit] the question to demonstrate those use cases too. Let me know how you want to proceed. – jcalz May 13 '23 at 21:01
  • In my approach, you are also restricted with the keys, so you can pass only the ones that exist in the object; however, in some cases, it may not be the thing that you actually want. My approach also includes index signatures handling, which may not be helpful for you. Example: `{[x: string]: {a: number}]` you can pick `string.a` – wonderflame May 13 '23 at 21:05
  • To be honest, @jcalz and wonderflame, both of your approaches are what I asked for. And to be even more honest, typing isn't my strong suit, and I'm not in a position to judge the merits of your answers. I couldn't really lean one way or the other, except that I have a little easier time understanding jcalz approach. Is there anything you guys would recommend to learn how to type like that? Any specific place or tutorial? I guess I would go with jcalz. But again, really for no more than I can grasp it more easily. – robquinn May 13 '23 at 22:01
  • I don't mind writing up an answer but... have you tested against your use cases? What do you want `PickByDotNotation` to be when `T` is a union? When `T` has an index signature? When `T` has optional properties? When `T` is an array type? Etc etc. I'd hate to spend the effort explaining *how* something works if it doesn't actually "work" to start with. – jcalz May 13 '23 at 22:05
  • The official handbook is really great, just look through it and try to do some stuff. Also, as @jcalz mentioned, you will need to answer those questions – wonderflame May 13 '23 at 22:12
  • Currently, the main difference in our approaches is that mine has strict key type checking, if that's not what you are looking for then I would suggest to go with @jcalz's solution – wonderflame May 13 '23 at 22:14
  • I don't fully know what you guys want, even though you've said it. But I'll try. `T` is always an object, and its properties are usually always the same too. `T` is a response from Google's Admin Directory [List](https://developers.google.com/admin-sdk/directory/reference/rest/v1/users/list) or [Get](https://developers.google.com/admin-sdk/directory/reference/rest/v1/users/get?apix_params=%7B%22userKey%22%3A%22mocha.banjo%40russlyon.com%22%2C%22customFieldMask%22%3A%22Roster%22%2C%22projection%22%3A%22basic%22%7D) methods. (need more characters hold on for next comment) – robquinn May 13 '23 at 22:21
  • You can check this [playground](https://tsplay.dev/wg8l1N) to see what I mean. You can't pass invalid keys – wonderflame May 13 '23 at 22:23
  • I just want `PickByDotNotation` to be the object with only the properties in the `K` array specified. Whatever the type of the property `K` is, is what it should be in `PickByDotNotation`. In regard to invalid keys, I'm still not following. I won't be passing invalid keys because I know what the keys are ahead of time. I think I need someone to help me with my stupidity. – robquinn May 13 '23 at 22:25
  • @robquinn Okay let me try a different way of asking: let's say I post an answer, and then later you realize that it doesn't actually meet your needs for some reason when you try to use it in your own code base. Would you come back here and not accept or unaccept the answer (this is what I'm trying to avoid)? Or would you just leave this question as acceptably answered and possibly open a new question for it (this would be fine)? – jcalz May 13 '23 at 22:26
  • If you try to pass a wrong `K`,ts in my approach will complain,in @jcalz's it won't, since he just expects strings and doesn't validate them – wonderflame May 13 '23 at 22:27
  • Oh, no. I wouldn't change the outcome. I'll accept it and leave it at that. I have it integrated in my codebase, but I need further time to study and understand everything fursure. If you'd like, I can post an edit and show you how I'm using it in my codebase? But either way, I'll accept and leave it at that. – robquinn May 13 '23 at 22:31
  • Okay I'll write up an answer when I get a chance. – jcalz May 13 '23 at 22:33

3 Answers3

1

The goal here is to write a PickByDotNotation<T, K> type where T is some object type and K is a string literal type corresponding to a dotted path in T, or possibly a union of such dotted path string types. And the output would be a supertype of T containing only the paths that start with those mentioned in K.

Once such a type exists you can give pickObjProps() a call signature like

declare function pickObjProps<T extends object, K extends string>(
  obj: T, paths: K[]
): PickByDotNotation<T, K>

A few caveats/disclaimers to get out of the way in the beginning:

  • I'm not worried about the implementation of pickObjProps() or getting the compiler to type check that the implementation satisfies the call signature. I'm assuming the implementation is correct (or if not, it's out of scope here) and that you will use whatever means necessary to get it to compiler with the call signature (like using type assertions or a single-call-signature overload etc).

  • I'm not worried about what happens if K has any invalid entries; if you write pickObjProps({a: 1, b: 2}, ["b", "c"]) it will be accepted and produce a value of type {b: number}.

  • I'm only really concerned with non-recursive object types for T, without index signatures and without optional properties, and without union-typed properties, and without trying to index into arrays with numeric properties like phones.0.type or phones[0].type. I tested my code against the example call in the question and that's it. There are lots of possible complications with types, and because PickByDotNotation<T, K> is necessarily deeply recursive, it's quite possible that my implementation will do something unexpected in the face of some of these. Such issues can often be worked around, but fixing them can sometimes require a complete refactoring. So beware!


So, my implementation looks like this:

type PickByDotNotation<T, K extends string> = {
    [P in keyof T as P extends (
        K extends `${infer K0}.${string}` ? K0 : K
    ) ? P : never]:
    P extends K ? T[P] : PickByDotNotation<
        T[P], K extends `${Exclude<P, symbol>}.${infer R}` ? R : never
    >
} & {} 

I'm using key remapping in mapped types to filter the keys of T. A mapped type of the form {[P in keyof T as ⋯ extends ⋯ ? P : never]: ⋯} will include all keys where the ⋯ extends ⋯ conditional type is true, and suppress all keys where it is false.

In the above, the check is whether the key P extends K extends `${infer K0}.${string}` ? K0 : K. This is a template literal type that parses K to get the part before the first dot, if there is a dot. So if K is "foo" | "bar.baz" | "bar.qux" | "blah.yuck" then K extends `${infer K0}.${string}` ? K0 : K will be "foo" | "bar" | "blah". So we're keeping all keys that appear as the first part of the paths in K, and suppressing all keys that don't.

As for the value type of the property, that's P extends K ? T[P] : PickByDotNotation<T[P], K extends `${Exclude<P, symbol>}.${infer R}` ? R : never>. If P happens to be the same as K then it means this isn't dotted and we just return keep the property type T[P] like a normal Pick. Otherwise K is dotted and we want to grab the part after the dot and do a recursive call to PickByDotNotation with T[P] as the new object. The part after the dot is K extends `${Exclude<P, symbol>}.${infer R}` ? R : never>. (I'm using the Exclude utility type to ignore the possibility that P is a symbol type which can't be treated like a string.) So if K is "foo" | "bar.baz" | "bar.qux" | "blah.yuck" and P is "bar" then K extends `${Exclude<P, symbol>}.${infer R}` ? R : never> will be "baz" | "qux".

That's mostly it. I added an intersection with the empty object type {}, but that's just for cosmetic purposes. If you leave it out then the compiler will show the output type of pickObjProps() will display in IntelliSense quickinfo as PickByDotNotation<{⋯}, "⋯" | "⋯" | "⋯" | ⋯> which isn't what people want to see. By writing & {} it prompts the type display to evaluate it fully and you get the keys and values written out.


Okay let's test it out on

const obj = {
    primaryEmail: "mocha.banjo@somecompany.com",
    suspended: false,
    id: 'aiojefoij23498sdofnsfsdfoij',
    customSchemas: {
        Roster: {
            prop1: 'val1',
            prop2: 'val2',
            prop3: 'val3'
        }
    },
    some: {
        deeply: {
            nested: {
                value: 2345945
            }
        }
    },
    names: {
        givenName: 'Mocha',
        familyName: 'Banjo',
        fullName: 'Mocha Banjo'
    },
    phones: [{
        type: 'primary',
        value: '+1 (000) 000-0000'
    }]

}

const result = pickObjProps(obj, 
  ['primaryEmail', 'customSchemas.Roster', 'some.deeply.nested']
);

The type of result can be seen as

/* const result: {
    primaryEmail: string;
    customSchemas: {
        Roster: {
            prop1: string;
            prop2: string;
            prop3: string;
        };
    };
    some: {
        deeply: {
            nested: {
                value: number;
            };
        };
    };
} */

Looks good. Only the properties whose paths begin with the elements of that array are present in the output type.

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360
  • Hey thanks @jcalz. I appreciate the super in-depth answer and the use of all the correct vocabulary for the terms at play. I learned quite a bit from that answer. No wonder you work where you do and have so much reputation lol. Thanks again! – robquinn May 14 '23 at 14:45
0

This does not really answer your question, but I had tried to solve the same problem before, and also chose dot notation at first. But I needed to change the nullability of some properties, i.e some properties were optional in the base type but should be required in the picked type and vice versa, which is cumbersome to express in dot notation. I ended up using a kind of object notation and think it may be of help. Using object notation also offers better auto-complete / intellisense support, avoid typos, avoid having to repeat the outer property name for nested properties.

This was how I implemented it. Please note that in my use case the nullability of properties of target type is not inherited from the original type but defined in the object instead, you may have to modify a little if it's not what you want.

import { SetOptional } from 'type-fest'

type PickDeepBaseOptions<T = unknown> = {
    $required?: readonly (keyof T)[]
    $optional?: readonly (keyof T)[]
}

type PickDeepOptions<T> = T extends unknown[] ? PickDeepOptions<T[number]> :
    PickDeepBaseOptions<T> & { [K in keyof T]?: PickDeepOptions<T[K]> }

type SetOptionalLax<BaseType, Keys> = SetOptional<BaseType, Keys & keyof BaseType>

type RequiredKeys<O> = O extends { $required: readonly string[] } ? O['$required'][number] : never
type OptionalKeys<O> = O extends { $optional: readonly string[] } ? O['$optional'][number] : never
type KeysToTransform<O> = Exclude<keyof O, keyof PickDeepBaseOptions>
type KeysToCopy<O> = Exclude<RequiredKeys<O> | OptionalKeys<O>, KeysToTransform<O>>

type PickDeep<T, O> =
    T extends unknown[] ? PickDeep<T[number], O>[] :
        SetOptionalLax<
            {
                [K in KeysToCopy<O> & keyof T]: NonNullable<T[K]>
            } & {
                [K in KeysToTransform<O> & keyof T]: PickDeep<NonNullable<T[K]>, O[K]>
            },
            OptionalKeys<O>
        >
/* ===== Usage: ===== */
const obj = {
    primaryEmail: 'mocha.banjo@somecompany.com',
    suspended: false,
    id: 'aiojefoij23498sdofnsfsdfoij',
    customSchemas: {
        Roster: {
            prop1: 'val1',
            prop2: 'val2',
            prop3: 'val3'
        }
    },
    some: {
        deeply: {
            nested: {
                value: 2345945
            }
        }
    },
    names: {
        givenName: 'Mocha',
        familyName: 'Banjo',
        fullName: 'Mocha Banjo'
    },
    phones: [{
        type: 'primary',
        value: '+1 (000) 000-0000'
    }]
}

const pickOPtions = { 
    $required: ['primaryEmail'],
    $optional: ['phones'],
    customSchemas: { $required: ['Roster'] },
    some: { deeply: { $required: ['nested'] }},
    phones: { $optional: ['value'] },
} satisfies PickDeepOptions<typeof obj>

type Target = PickDeep<typeof obj, typeof pickOPtions>
/* ===>
type Target = {
    primaryEmail: string;
    customSchemas: {
        Roster: {
            prop1: string;
            prop2: string;
            prop3: string;
        };
    };
    some: {
        deeply: {
            nested: {
                value: number;
            };
        };
    };
    phones?: {
        value?: string | undefined;
    }[] | undefined;
}
*/

I also add phones property to the pickOptions object to demonstrate $optional and picking inside arrays. I understand that you may already have a pick function that uses dot notation. In that case you can create either a helper function to transform the pickOptions object to list of dot notation keys, or a new pick function

Can Nguyen
  • 1,470
  • 10
  • 11
0

Code from this answer.

It even works with arrays and optional properties.

type DeepPick<T, K extends string> = T extends object ? {
  [P in Head<K> & keyof T]: T[P] extends readonly unknown[] ? DeepPick<T[P][number], Tail<Extract<K, `${P}.${string}`>>>[] : DeepPick<T[P], Tail<Extract<K, `${P}.${string}`>>>
} : T
type Head<T extends string> = T extends `${infer First}.${string}` ? First : T;
type Tail<T extends string> = T extends `${string}.${infer Rest}` ? Rest : never;

Usage example:

interface TestBook {
  id: string;
  name: string;
}

interface TestUser {
  id: string;
  email: string;
  books: TestBook[];
  book: TestBook,
}

type T = DeepPick<TestUser, "id" | "books.name" | "book.id">;
//T = {
//  id: string;
//  books: {
//    name: string;
//  }[];
//  book: {
//    id: string;
//  };
//}

Playground

Maurici Abad
  • 1,012
  • 9
  • 20