Assuming that the id
and path
are necessary, and that children
do not have to be a mutable array type, my definition of RouteObject
is as follows:
type RouteObject = {
id: string,
path: string,
children?: readonly RouteObject[]
};
(It's worth noting that readonly
arrays offer less restriction than mutable ones despite the name.)
In order for this setup to function properly, it is essential for the compiler to keep track of the string literal types associated with all nested id
and path
properties in your route objects. By annotating routeObjects
like this:
const routeObjects: RouteObject[] = [ ⋯ ];
You are instructing the compiler to discard any specific information from that initializing array literal. Even without the annotation, if you define it as:
const routeObjects = [ ⋯ ];
The compiler will default to inferring just string
for the nested id
and path
properties since narrow inference is usually preferred. To achieve precise inference, a const
assertion can be used:
const routeObjects = [ ⋯ ] as const;
With this approach, the type of routeObjects
reflects a highly specific readonly
tuple type:
/* const routeObjects: readonly [{
readonly id: "root";
readonly path: "/";
readonly children: readonly [{
readonly id: "auth";
readonly path: "auth";
readonly children: readonly [⋯] // omitted for brevity
}, {⋯}]; // omitted for brevity
}] */
This is why I extended children
to allow readonly RouteObject[]
; otherwise, it would fail to qualify as a RouteObject[]
.
Now, we are equipped with a strong typing foundation.
The implementation specifics of createRoutes()
remain largely untouched as verifying its conformance to what could potentially be a complex call signature is unfeasible by the compiler. Instead, the focus shifts towards encapsulating the implementation within a single-call-signature overload, or an equivalent construct. Moving forward, attention is diverted toward the call signature rather than the actual function implementation:
declare function createRoutes<T extends readonly RouteObject[]>(
routeObjects: T
): Routes<T>;
Hence, createRoutes()
evolves into a generic function contingent upon the type T
pertaining to routeObjects
constrained within readonly RouteObject[]
. It yields an object of type Routes<T>
, characterized by intricate structure manipulation:
type Routes<T extends readonly RouteObject[]> = {
[I in keyof T]: (x:
Record<T[I]['id'], T[I]['path']> & (
T[I] extends {
path: infer P extends string,
children: infer R extends readonly RouteObject[]
} ? { [K in keyof Routes<R>]:
`${P extends `${infer PR}/` ? PR : P}/${Extract<Routes<R>[K], string>}`
} : {}
)
) => void
}[number] extends (x: infer U) => void ?
{ [K in keyof U]: U[K] } : never;
Despite its complexity, this recursive type defines Routes<T>
through iterations involving Routes<R>
for each R
corresponding to the children
property of elements in T
.
Let us dissect the functionality of Routes<T>
:
An object type is constructed for each element (indexed by I
) within the tuple type
T</code, wherein the sole key represents the <code>id
property mapping to the respective path
property - articulated through Record<<T[I]['id'], T[I]['path']>
using the Record
utility type. For instance, the initial element in routeObjects
translates to {root: "/"}
.
If the element encompasses a children
property R
(verified via conditional type inference leveraging
T[I] extends { ⋯ children: infer R ⋯ } ? ⋯
), a recursive evaluation of the object type Routes<R>
is also carried out. Consequently, an output akin to { auth: "auth"; login: "auth/login"; vishal: "auth/login/vishal"; register: "auth/register"; ⋯ }
materializes for such elements.
Subsequently, Routes<R>
undergos mapping where the current path
property is prepended with a slash (but avoids adding a redundant slash if already present at the end; tweaking may be required depending on the implementation). This metamorphosis is depicted through
{ [K in keyof Routes<R>]: `${P extends `${infer PR}/` ? PR : P}/${Extract<Routes<R>[K], string>}`}
. Thus, elements transform along lines of { auth: "/auth"; login: "/auth/login"; vishal: "/auth/login/vishal"; register: "/auth/register"; ⋯ }
.
Both these object types converge through an intersection. In cases where no children
exist, intersection involves solely an empty object type {}
.
All constituent pieces amalgamate resulting in a substantial union U
. Employing techniques akin to
TupleToIntersection<T></code elaborated in response to <a href="https://stackoverflow.com/a/74202280/2887218">TypeScript merge generic array</a>, individual segments are placed in a <a href="https://en.wikipedia.org/wiki/Covariance_and_contravariance_(computer_science)" rel="nofollow noreferrer">contravariant</a> type stance, consolidated into a union, and eventually distilled into one encompassing intersection. The process is aptly captured within <code>{ [I in keyof T]: (x: ⋯ ) => void}[number] extends (x: infer U) ⇒ void ? ⋯ : never
. Hence, if navigating through the array yields [{a: "b"} & {c: "b/d"}, {e: "f"} & {g: "f/h"}]
, culmination occurs in the unified interaction of {a: "b"} & {c: "b/d"} & { e: "f"} & {g: "f/h"}
.
To enhance readability amidst a large intersection type, a direct identity-mapping lineage over U
leads to consolidation into a singular object type - evidently portrayed progress through { [K in keyof U]: U[K] }
. A scenario representing
{a: "b"} & {c: "b/d"} & {e: "f"} & {g: "f/h"}
would culminate into {a: "b"; c: "b/d"; e: "f"; g: "f/h"}
.
A comprehensive breakdown indeed! Time for validation:
const routes = createRoutes(routeObjects);
/* Outputting:
{
root: "/";
auth: "/auth";
login: "/auth/login";
vishal: "/auth/login/vishal";
register: "/auth/register";
resetPassword: "/auth/reset-password";
resendConfirmation: "/auth/resend-confirmation";
playground: "/playground";
playgroundFormControls: "/playground/form-controls";
}
*/
Mission accomplished - delivering exactly the insight anticipated.
Playground link to code