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
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.