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

Time:01-10

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

CodePudding user response:

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

  • Related