I'm trying to build a recursive type in TypeScript that will resolve each possible route path, including nested paths, from a given route configuration. However, I'm encountering issues with type constraints and inference.
I have defined the routes using a TypedRoute interface and a createRoute function:
interface TypedRoute<
Path extends string,
QueryParams extends Record<string, string> = {}
> {
path: Path;
queryParams?: QueryParams;
children?: TypedRoute<any, any>[];
}
function createRoute<
Path extends string,
QueryParams extends Record<string, string> = {}
>(config: TypedRoute<Path, QueryParams>) {
return config;
}
const routes = [
createRoute({
path: 'results/:id/foo/:bar'
}),
createRoute({
path: 'home',
children: [
createRoute({
path: 'bar',
children: [
createRoute({ path: 'baz' })
]
}),
],
}),
]
Now, I'm trying to create a type ExtractRoutePaths that recursively extracts all possible paths:
type ExtractRoutePaths<T extends readonly TypedRoute<any, any>[]> =
T extends readonly []
? never
: T extends readonly [infer First, ...infer Rest]
? First extends TypedRoute<infer P, any>
? First extends { children: infer C }
? C extends readonly TypedRoute<any, any>[]
? `{P}` | ExtractRoutePaths<C, `${P}/`> | ExtractRoutePaths<Rest extends readonly TypedRoute<any, any>[] ? Rest : never>
: `${P}` | ExtractRoutePaths<Rest extends readonly TypedRoute<any, any>[] ? Rest : never>
: `${P}` | ExtractRoutePaths<Rest extends readonly TypedRoute<any, any>[] ? Rest : never>
: ExtractRoutePaths<Rest extends readonly TypedRoute<any, any>[] ? Rest : never>
: never;
I expect the Routes to be:
results/:id/foo/:bar | home | home/bar | home/bar/baz
What am I doing wrong, and how can I correctly build this recursive type to extract all possible paths, including nested ones?
In what follows I'm ignoring the queryParams
stuff which doesn't seem to be directly relevant. You can add it back into your own types, but for ExtractRoutePaths<T>
it's not needed. Indeed I'll only look at the type
interface BaseRoute {
path: string;
children?: BaseRoute[]
}
which is all you need to extract the paths. I'll write ExtractRoutePaths<T>
to operate on a single route; if you need to operate on an array of routes you can write
type ExtractRouteArrayPaths<T extends readonly BaseRoute[]> =
ExtractRoutePaths<T[number]>;
Okay, so here's ExtractRoutePaths<T>
:
type ExtractRoutePaths<T extends BaseRoute> = T["path"] | (
T extends { children: infer C extends readonly BaseRoute[] } ?
`${T["path"]}/${ExtractRoutePaths<C[number]>}` : never
)
This is a recursive conditional type and fairly straightforward. We know that it will always include T["path"]
(the type you get when you index into a T
value with the path
property key). So we take that and put it in a union with the recursive step. We use infer
and extends
in a conditional type to check that T
actually has a children
property of the expected type (since it's optional it might not exist or it might be undefined
) and store that property in the type variable C
. If not, then we don't need to recurse and we have our base case (which is just never
). If so, then we return a template literal type which joins the current path with the union of all child paths, with a slash in between.
Note that the conditional type here is distributive over union types, and so are indexed accesses, and so are template literal types, so the single type above will naturally collect all the different paths into a single union.
To test it, we should give it a route type. But before we can do that we need to modify your createRoute()
so that it keeps track of exactly what you give to it. We don't want it to just infer the top path type, which is what your version does. It's better to make it generic in the type of the whole config
parameter, like this:
function createRoute<const T extends BaseRoute>(config: T) {
return config;
}
And note the const
type parameter which asks TypeScript to infer narrow literal types for literal input values, rather than inferring wider types like string
.
So now we can test it:
const routes = [
createRoute({
path: 'results/:id/foo/:bar'
}),
createRoute({
path: 'home',
children: [
createRoute({
path: 'bar',
children: [
createRoute({ path: 'baz' })
]
}),
],
}),
]
type Z = ExtractRouteArrayPaths<typeof routes>;
// type Z = "results/:id/foo/:bar" | "home" | "home/bar" | "home/bar/baz"
Looks good. You can see that routes
has enough type information to represent the entire tree structure, and ExtractRouteArrayPaths
(and therefore ExtractRoutePaths
) extracts it as a union of corresponding paths.
Do note that deeply recursive types like this tend to have bizarre edge cases, and sometimes require significant refactoring to handle them. So it's important to test any such type against a wide range of representative use cases, and be prepared to have to maintain it more than you'd like in the face of future use cases, or even language changes.