1

What is the mapped type for configToMap function below to produce the desired result type?

type RouteNode = {
  name: string;
  query?: Record<string, string|number>;
  routes?: RouteNode[];
};

const configToMap = (config: RouteNode[]) {
  // implementation not important
}

configToMap([

      {
        name: "home",
        query: {
          from: "test"
        }
      },
      {
        name: "placesApp",
        routes: [
          {
            name: "placesList",
            query: {
              search: "beach"
            }
          },
          {
            name: "place",
          },
        ]
      }

])

// return type should be
{
  home: {
    query: {
      from: string;
    }
  },
  placesList: {
    query: {
      search: string;
    }
  },
  place: {}
}

Clarifications:

  • When a route node doesn't have a routes prop, the node's name prop should be a key in the result type and the value should be the rest of the props in that node (e.g query)
Adrian Adkison
  • 3,537
  • 5
  • 33
  • 36
  • 1
    Could you break this down into a smaller task? Right now I'm not following everything going on and it's probably because you're asking for what looks like a lot of fiddly things. Like, what do I do with `"*"` as a path? Or `"/"`, or `""`? If you could reduce the scope of what you're looking for and then later (in a followup question maybe) *add* features to it, it would probably be easier for people to address. – jcalz Oct 19 '22 at 16:09
  • Thank you. I've simplified the question and example. – Adrian Adkison Oct 19 '22 at 19:05
  • What if a route's name conflicts with a key in the parent? – kelsny Oct 19 '22 at 19:16
  • 1
    Does [this approach](https://tsplay.dev/NrX3oW) meet your needs? If so I can write up an answer; if not, what am I missing? (Respond with @jcalz in your reply to notify me) – jcalz Oct 19 '22 at 19:21
  • All names in the structure have to be unique. I can either enforce that in the implementation. If there is a way for typescript to enforce that, it would be even better but not required. – Adrian Adkison Oct 19 '22 at 19:22
  • @jcalz wow, nailed it. So many questions. What does `=> null!` mean? What does readonly do to help solve the problem? If looks like you're creating a function type in RouteNodeArrToMap, why is that needed. This is amazing. I feel like I was nowhere near to getting this. Any Typescript reading recommendations to get to this level? – Adrian Adkison Oct 19 '22 at 19:33
  • 1
    I'm not sure how to answer all these questions here, although I was planning to explain relevant things in my answer. Irrelevant thing: that `=> null!` is just a dummy implementation that returns `null` at runtime but we're asserting it's of the `never` type and... well, if you're focused on that it's the wrong thing to focus on. Would using `declare` syntax be better, like [this](https://tsplay.dev/N9nQ7m)? My goal here is to focus on typings and not implementation. – jcalz Oct 19 '22 at 19:38
  • 1
    Hmm, I'd also say that `readonly` is probably not relevant... readonly arrays are more accepting than regular arrays, but you don't need it for this question so I'll remove it like [this](https://tsplay.dev/mMBOdW). That leaves the "why is the function type needed" question which I would explain in the answer anyway, pointing to https://stackoverflow.com/questions/50374908/transform-union-type-to-intersection-type – jcalz Oct 19 '22 at 19:40
  • @jcalz your last version works great, removes two of my questions. And your other link answers why the function is used. – Adrian Adkison Oct 19 '22 at 20:29
  • 1
    I will write up an answer when I get a chance yet. Kind of backlogged right now but I hope to get to it today – jcalz Oct 19 '22 at 20:30

1 Answers1

1

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

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

jcalz
  • 264,269
  • 27
  • 359
  • 360