1

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

jcalz
  • 264,269
  • 27
  • 359
  • 360
  • 1
    This may be a duplicate of [this question](https://stackoverflow.com/q/74705564/2887218) or [this question](https://stackoverflow.com/q/58434389/2887218) but I'm not sure; your title wants just a union of all paths, but your question seems to only want a union of all paths whose property value is of type `{visible: boolean}`. Which is it? – jcalz Jan 09 '23 at 23:37
  • @jcalz all paths whose property value has visible prop – Clifford B. Wolfe Jan 09 '23 at 23:40
  • @jcalz Go ahead. I am not sure how to make the title clear – Clifford B. Wolfe Jan 09 '23 at 23:43
  • Your answer to the other post might solve my issue. Will take some time to read. Thanks – Clifford B. Wolfe Jan 09 '23 at 23:48
  • 1
    Does [this approach](https://tsplay.dev/NB4O4W) meet your needs? If so I could possibly write up an answer (although it's similar to the others); if not, what am I missing? – jcalz Jan 10 '23 at 00:00
  • 1
    Okay I will write up an answer when I get a chance; might not be for a few hours – jcalz Jan 10 '23 at 00:23

1 Answers1

1

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

jcalz
  • 264,269
  • 27
  • 359
  • 360