Search code examples
typescriptunion-types

Get union of all dotted object paths to properties of a particular type


I have an object representing dom tree visibility

const visibilities = {
    food: {
        visible: true,
        fruit: {
            visible: true,
            apple: {
                visible: false
            }
        },
        snack: {visible: false}
    }
}

I want to get the visibility of apple by using a util function

getVisibilities(visibilities, 'food.fruit.apple')

but I don't know how to type the second argument. I tried to connect keys when visibilities[K] is type of {visibility: boolean}

type VisibilityString<Prop extends {[key: string]: any}> = {[K in keyof Prop]: Prop[K] 
   extends {visible: boolean} ? K 
     extends string ? K | `${K}.${VisibilityArray<Prop[K]>}`: never : never}[keyof Prop]

got TypeError: VisibilityArray<Prop[K]> is not a string

Changing the second argument to array works but typescript still gives error in version 4.4.4

Playground link


Solution

  • These sorts of deeply-nested recursive conditional types often have quite surprising and unpleasant edge cases, and there can be a fine line between a type which meets all your needs and one which results in obnoxious circularity warnings and compiler slowdowns. So while I present one possible approach below which works for your example code, be warned that this is tricky and you might well hit a problem that requires a complete refactoring to overcome.


    Anyway, here's one way to do it:

    type _DKM<T, V> =
      (T extends V ? "" : never) |
      (T extends object ? { [K in Exclude<keyof T, symbol>]:
        `${K}.${_DKM<T[K], V>}` }[Exclude<keyof T, symbol>] : never)
    
    type TrimTrailingDot<T extends string> = T extends `${infer R}.` ? R : T;
    
    type DeepKeysMatching<T, V> = TrimTrailingDot<_DKM<T, V>>
    

    The helper type _DKM<T, V> stands for DeepKeysMatching<T, V> and does most of the work. The idea is that it takes a type T and should produce a union of all the paths in that type that point to a value of type V. We'll see that it actually produces a union of these paths with a trailing dot appended to them, so we'll need to trim this dot afterward.

    Basically: if T itself is of type V, then we want to return at least the blank path "". Otherwise we don't and return never. If T is not an object type we're done; otherwise we map _DKM<T[K], V> over each of the properties at keys K, and prepend each key K and a dot to it. This gives us everything we want except for that trailing dot.

    So TrimTrailingDot<T> will remove a trailing dot from a string if there is one.

    And finally DeepKeysMatching<T, V> is defined as TrimTrailingDot<_DKM<T, V>>, so that we're just stripping that dot.


    Armed with that we can define getVisibilities():

    declare function getVisibilities<T>(visibilities: T,
      path: DeepKeysMatching<T, { visible: boolean }> & {} 
    ): boolean;
    

    It's generic in the type T of the visibilities parameter, and then we limit the path parameter to be the union of paths of T that point to properties of type {visible: boolean}, hence DeepKeysMatching<T, { visible: boolean }>.

    By the way, that & {} doesn't really do anything to the type (the empty object type {} matches everything except undefined and null, so intersecting a string with it will end up being the same string), but it does give IntelliSense a hint that we'd like it to display the type of path as a union of string literals instead of the type alias DeepKeysMatching<{...}, { visible: boolean }>. The alias might be useful in some circumstances, but presumably you want callers to be shown a specific list of values.


    Let's test it out:

    const visibilities = {
      food: {
        visible: true,
        fruit: {
          visible: true,
          apple: {
            visible: false
          }
        },
        snack: { visible: false }
      }
    }
    
    getVisibilities(visibilities, "food.fruit.apple");
    // function getVisibilities(
    //   visibilities: {...}, 
    //   path: "food.fruit.apple" | "food" | "food.fruit" | "food.snack"
    // ): boolean
    

    Looks good. When we call getVisibilities(visibilities, ..., we are then prompted for a path argument of type "food.fruit.apple" | "food" | "food.fruit" | "food.snack".

    getVisibilities(
       { a: 1, b: { c: { d: { visible: false } } } }, 
       "b.c.d"
    );
    

    Also looks good, we are prompted that only the path "b.c.d" is acceptable.


    So we're done, and it works. As I said earlier, I'm sure there are plenty of edge cases to deal with, but this at least answers the question as asked.

    Playground link to code