Issue
Given the following object structure:
type IObject = { id: string, path: string, children?: IObject[] }
const tree = [
{
id: 'obj1' as const,
path: 'path1' as const,
children: [
{
id: 'obj2' as const,
path: ':pathParam' as const,
children: [
{
id: 'obj3' as const,
path: 'path3' as const
}
]
},
{
id: 'obj4' as const,
path: 'path4' as const,
children: [
{
id: 'obj5' as const,
path: 'path5' as const
}
]
}
]
}
];
I am trying to extract the type of a nested object PLUS the an accumulation of the path fragments to that nested type. (assuming ids are unique across the object)
example
type ok = FindAndTrackDeepPath<'obj2', typeof tree>['fullPath'];
// type ok = "/path1/:pathParam"
I tried the following which i think is close. However, as you can see in the second (non working) example, there seems to be an issue when descending the type recursively.
export type FindAndTrackDeepPath<
ID,
T extends IObject[],
depth extends number = 1,
path extends string = '',
maxDepth extends number = 10
> = depth extends maxDepth
? never
: FindByID<ID, T> extends never
? FindAndTrackDeepPath<
ID,
Descend<T>['children'],
Increment<depth>,
Descend<T>['path'] extends undefined ? '' : `${path}/${Descend<T>['path']}`
>
: FindByID<ID, T> & { fullPath: `${path}/${FindByID<ID, T>['path']}` };
// helpers
type FindByID<ID, T extends IObject[]> = Extract<T[number], { id: ID }>;
type Descend<T extends IObject[]> = Extract<T[number], { children: IObject[] }>;
type Increment<N extends number> = [1,2,3,4,5,6,7,8,9,10,11,12,...number[]][N];
// examples
type ok = FindAndTrackDeepPath<'obj2', typeof tree>['fullPath'];
// type ok = "/path1/:pathParam"
type notOK = FindAndTrackDeepPath<'obj3', typeof tree>['fullPath'];
// type notOK = "/path1/:pathParam/path3" | "/path1/path4/path3"
type notOK has two alternatives, one of which is obviously wrong.
I think the problem is that when descending recursively a union of all options at each level is created in a "breadth first" kind of way. (i.e. somthing like this happens: ${("/path1/:pathParam" | "/path1/path4")}/path3"}) I have tried several ways to further prune unsuccessful descends/"parts of the unions" while descending. To no avail.
I have found a few related questions here and here. These, however, extract all possible paths. Instead i want to only extract the path for a child type matching specific criteria (in my case extends { id: ID}).
Can anyone help me out here? Is this even possible?
Solution
Here's one possible implementation of what I'm calling IdxWithFullPath<I, T>, where I is the generic id type you're looking for inside of the IObject-or-IObject[] T type. It will evaluate to the intersection of the IObject that has an id assignable to type I, and an object with a fullPath property corresponding to the concatenation of all the path properties in the tree from the root down to the IObject in question:
type IdxWithFullPath<I, T, P extends string = ""> =
T extends IObject[] ? IdxWithFullPath<I, T[number], P> :
T extends IObject ? (
I extends T["id"] ? (
T & { fullPath: `${P}/${T["path"]}` }
) : (
IdxWithFullPath<I, T["children"], `${P}/${T["path"]}`>
)
) :
never;
There's a third generic type parameter P corresponding to the parent path; this allows IdxWithFullPath to be recursive. It defaults to the empty string literal type, the path of the root of the tree.
Note that it is a distributive conditional type in T. That means if T is a union, then it will be split into individual members which will be processed separately, and the results will be joined back into a union. Since the never type gets absorbed into unions, that means we can just return never for anything we don't want to include.
So, if T is an array of IObjects, then we want to operate instead on T[number], the union of IObject element types.
Otherwise, if T is an IObject, we check its id property. If it matches I then we return the desired intersection, where the fullPath is the parent path stored in P concatenated with the path property of the current IObject, using template literal types. If it doesn't match I then we have to recurse down into children, appending path to P to be the new parent path.
And finally, if T is something else then we return never so that it's not included in the result. This will happen when the search bottoms out, such as with a missing children property: I think if children is missing, then T["children"] will evaluate to unknown, and when children is present but undefined then T["children"] will evaluate to undefined. We want neither of those to be included.
Let's test it out:
type Ok = IdxWithFullPath<'obj2', typeof tree>;
/* type Ok = {
id: "obj2";
path: ":pathParam";
children: {
id: "obj3";
path: "path3";
}[];
} & {
fullPath: "/path1/:pathParam";
} */
type AlsoOK = IdxWithFullPath<'obj3', typeof tree>;
/* type AlsoOK = {
id: "obj3";
path: "path3";
} & {
fullPath: "/path1/:pathParam/path3";
} */
Looks good. Note that such deeply recursive conditional types tend to have bizarre edge cases that sometimes require significant refactoring in order to resolve. With any such type, you should test heavily against expected use cases before proceeding, and be prepared to have to modify or even abandon an approach if you find unacceptably bad edge case behavior.
Answered By - jcalz
0 comments:
Post a Comment
Note: Only a member of this blog may post a comment.