In order for this to work we need configToMap()
to be generic in the type T
of its config
argument, so that we can then transform this type T
via some type function like RouteNodeArrToMap<T>
to represent the return type of the function.
Conceptually we want
declare const configToMap: <T extends RouteNode[]>(config: T) => RouteNodeArrToMap<T>
where T
is just constrained to some type assignable to an array of RouteNode
s.
Unfortunately the compiler will, by default, not keep track of the structure of config
we probably want to know about, like the order and length of the routes
properties and subproperties (e.g, tuple types instead of just arrays), and the literal types of the name
properties and subproperties. It would be nice if I could just ask the compiler to infer T
"narrowly" in a simple way, in the same way that const
assertions work on literal expressions. Alas, there's no simple approach to that yet (see microsoft/TypeScript#30680), so instead we have to use some tricks to convince the compiler to do this:
declare const configToMap: <T extends RouteNodeInfer<S>[], S extends string>(
config: [...T]
) => RouteNodeArrToMap<T>
where RouteNodeInfer
is defined as
type RouteNodeInfer<S extends string> = {
name: S;
query?: Record<string, string | number>;
routes?: RouteNodeInfer<S>[] | [];
}
By making RouteNodeInfer
generic in a type parameter S
constrained to string
, the compiler will try to infer string literal types for values in that position. And by making routes
include the empty tuple type []
, the compiler will try to infer tuples for values in that position. The type [...T]
also uses a variadic tuple type to ask the compiler to infer T
itself as a tuple.
So now when you call
const mapped = configToMap([
{
name: "home",
query: {
from: "test"
}
},
{
name: "placesApp",
routes: [
{
name: "placesList",
query: {
search: "beach"
}
},
{
name: "place",
},
]
}
]);
the type T
is inferred as
[
{ name: "home"; query: { from: string; }; },
{ name: "placesApp"; routes: [
{ name: "placesList"; query: { search: string; }; },
{ name: "place"; }
]; }
]
which has all the information we need.
Okay, so given T extends RouteNode[]
, how do we compute RouteNodeArrToMap<T>
? Here's one way:
type RouteNodeArrToMap<T extends RouteNode[]> =
{ [I in keyof T]: (x: RouteNodeToMap<T[I]>) => void }[number] extends (x: infer I) => void ?
{ [K in keyof I]: I[K] } :
never;
What this does is compute RouteNodeToMap<T>
(to be defined layer) for each RouteNode
element of the T
array, and intersect them together, and then map that intersection to a single object type. We want intersections because if you have {foo: {}}
for one RouteNode
and {bar: {}}
for another RouteNode
, the combined output should be {foo: {}, bar: {}}
, which is nearly equivalent to {foo: {}} & {bar: {}}
. The technique for converting a tuple of things into an intersection of its elements comes from this Q/A (and I won't go through the details here).
Okay, so RouteNodeArrToMap<T>
is defined in terms of RouteNodeToMap<T>
which we haven't defined yet. Let's do that now:
type RouteNodeToMap<T extends RouteNode> =
T extends { routes: infer R extends RouteNode[] } ?
RouteNodeArrToMap<R> :
{ [P in T['name']]: T extends { query: infer Q } ? { query: Q } : {} }
This is a conditional type where we check if the RouteNode
has a defined routes
property of type R
. If so, we just return RouteNodeArrToMap<R>
(which completes the recursive cycle for this definition, allowing trees to be fully traversed). If not, then we want to produce a type whose key is the name
property of T
, and whose value we get by picking the query
property from it. The {[P in T['name']: ...}
is a mapped type with the key we want, and the T extends {query: infer Q} ? {query: Q} : {}}
is a conditional type which returns an object with the same query
property as T
, unless T
doesn't have one, in which case we return an empty object type (without this you get unknown
, I think.)
Okay, let's test it by looking at the type of mapped
:
/* const mapped: {
home: {
query: {
from: string;
};
};
placesList: {
query: {
search: string;
};
};
place: {};
} */
Looks good. This is the type that you wanted.
So there you go, hooray!
Now for the caveat. Deeply nested recursive types like RouteNodeArrToMap<T>
tend to have lots of edge cases and weird/surprising behavior. For example, if you pass in a T
type which is itself recursive, then you will get circularity warnings or performance problems.
Lots of other things could disrupt the computation... what should happen if you pass in a union for T
or one of its subproperties? Or a type with optional properties? Or index signatures? Maybe these situations aren't likely or possible to happen. Maybe they do happen but you don't care what comes out.
But probably there are some situations where the type function defined here does "the wrong thing". At which point, who knows? Maybe the definition could be easily tweaked to accommodate the requirement. Or maybe it needs a full refactor or rewrite. Or maybe it's impossible.
My point is that you should subject recursive mapped conditional types like this to careful scrutiny to make sure that you know what it will do in the face of real-world use cases, before using the type in a production environment.
Playground link to code