Home > Mobile >  Is it possible to create a type that recursively enforce values?
Is it possible to create a type that recursively enforce values?

Time:09-17

Consider this data structure :

const routes = {
  root: '/',
  foo: '/foo',         //ok
  bar: {
    root:'/bar',
    foo: '/bar/foo',   // ok 
    bar: '/bar/:barId',// ok
    baz: '/baz'        // should ko
  },
} as const;

Is it possible to write a type that would enforce the values inheriting from the parent & local root property ?

CodePudding user response:

If I'm understanding your question correctly, I think you can achieve this with a conditional mapped type and template literals:

interface Root {
    root: string;
}

type Routes<T extends Root> = {
    [P in keyof T]: P extends "root"
        ? T[P]
        :  P extends string
            ? T[P] extends Root
                ? T[P]["root"] extends `${T["root"]}${P}`
                    ? Routes<T[P]>
                    : never
                : `${T["root"]}${string}`
            : never;
};

Here is an annotated version of Routes to help explain how it works:

// For any object that has a "root" key that is a `string`
type Routes<T extends Root> = {
  // For each key in the object
  [P in keyof T]:
    // If the key is the "root" key
    P extends "root"
      // then its type is whatever it originally was (which is `string`)
      ? T[P]
      // otherwise if the key is a string other than "root"
      : P extends string
        // and if the key's value is an object that also has a "root" key that's a `string`
        ? T[P] extends Root 
          // and if the value of that object's "root" key begins with the value of the current object's "root" key   the name of the key we're currently examining
          ? T[P]["root"] extends `${T["root"]}${P}`
            // then the sub-object is itself a `Routes`
            ? Routes<T[P]>
            // otherwise it is an object in a different form that we don't allow
            : never
          // and if it wasn't an object, then it must be a `string` that begins with the value of the current object's "root" key
          : `${T["root"]}${string}`
        // otherwise the key we were examining couldn't be coerced into a string (e.g. a symbol)
        : never;
};

Examples to demonstrate that it works:

// This is a valid `Routes`.
const one = {
    root: "/",
    foo: "/foo",
    bar: "/bar",
} as const;

// This is not a valid `Routes` because `two.bar.baz` doesn't begin with "/bar".
const two = {
    root: "/",
    foo: "/foo",
    bar: {
        root: "/bar",
        foo: "/bar/foo",
        bar: "/bar/:barId",
        baz: "/baz",
    },
} as const;

// This is not a valid `Routes` because `three.bar.root` is not "/bar".
const three = {
    root: "/",
    foo: "/foo",
    bar: {
        root: "qux",
        foo: "qux/foo",
        bar: "qux/:barId",
        baz: "qux",
    },
} as const;

const oneRoutes: Routes<typeof one> = one;
const twoRoutes: Routes<typeof two> = two;
const threeRoutes: Routes<typeof three> = three;

In this example, the assignment of oneRoutes will be accepted by the compiler, but the assignments of twoRoutes and threeRoutes will produce errors. See the linked TS Playground below to read the full error.

TS Playground

  • Related