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