typescripttypescript-typingstypescript-generics

How can I retrieve a deeply nested property type where I know the key, but not the path?


I'm trying to create a generic type that can retrieve the type of a specific key from a potentially nested object type. It will receive the object, and the key as generics.

The path to that key isn't known though, so it potentially needs to iterate over each value to find it. Here is what I've come up with so far.

type GetDeepProp<T extends object, Key extends string> = Key extends keyof T 
  ? T[Key]
  : {
   [k in keyof T]: T[k] extends object 
    ? GetDeepProp<T[k], Key>
    : unknown
  }[keyof T]

//examples
type ShallowUser = { region: string; }
type DeepUser = { account: { region: number; } }
type DeeperUser = { account: { location: { region: "USA" | "Canada" | "France", } } }

let shallowRegion: GetDeepProp<ShallowUser, 'region'> // should return string
let deepRegion: GetDeepProp<DeepUser, 'region'> //should return number
let deeperRegion: GetDeepProp<DeeperUser, 'region'> //should return "USA" | "Canada"

After poking at this for a while, I've found this solution that actually seems to work. But I don't understand why it works. Should not this very last line [keyof T] create create a union of all the types from all the keys, and not just the key passed in? I guess my question is, why does this work (or maybe it doesn't hold up), and is there a better way to accomplish this?


Solution

  • It's ultimately up to you to decide what you want to see in various edge case situations, like multiple properties with the same property name at different depths of the object, or what to do in the face of unions or optional properties or index signatures, etc. For now I will mostly ignore such issues, since it's nearly impossible to anticipate all possible uses and address them.


    Here's how one might type GetDeepProp<T, K>:

    type GetDeepProp<T extends object, K extends string> = K extends keyof T
      ? T[K] : { [P in keyof T]: GetDeepProp<Extract<T[P], object>, K> }[keyof T]
    

    This is similar to yours except that GetDeepProp<Extract<T[P], object>, K> will distribute over unions in T[P], and in the case where T[P] is not an object, this evaluates to never and not unknown.

    It's true that {[P in keyof T]: ...}[keyof T] is a mapped type into which you immediately index to get a union of all the property values. The reason this will work for you is that for irrelevant keys you want the mapped type's property values to be the never type, since the compiler collapses XXX | never to XXX for all types XXX: the never gets absorbed in the union.

    So you don't want unknown because it has the opposite behavior with unions: the compiler collapses XXX | unknown to just unknown for all types XXX. You don't want sibling properties to obliterate your result with unknown:

    type DeepUser = { account: { region: number; }, other: number }
    let oops: OrigGetDeepProp<DeepUser, 'region'> // unknown for your definition
    let deepRegion: GetDeepProp<DeepUser, 'region'> // number for new definition
    

    The other property destroys the result with your version of GetDeepProp, while this version gives number.


    There you go. As I said, you will probably have some use cases where this definition does things you don't expect or like. If they are minor, I might be able to update the answer to deal with them. Otherwise, you might need to address this yourself or make a new post with more detailed requirements.

    But hopefully this at least explains the general approach here enough to be useful to you.

    Playground link to code