Generic constraint between two properties of an object

Viewed 152

I'm trying to improve types for a frontend route config object. I'm trying to tie the path and prepare fields together so that the prepare function field receives the appropriate parameters based on the :keys in the path.

Each route item in the config (other fields omitted for brevity) looks something like this:

interface Routes<Path extends string> {
    path: Path;
    prepare?: (params: PathArgs<Path>) => object;
    routes?: Routes[];
}

The helpers used to extract the params from the path string are

type PathParams<
  Path extends string
> = Path extends `:${infer Param}/${infer Rest}`
  ? Param | PathParams<Rest>
  : Path extends `:${infer Param}`
  ? Param
  : Path extends `${infer _Prefix}:${infer Rest}`
  ? PathParams<`:${Rest}`>
  : never;

type PathArgs<Path extends string> = { [K in PathParams<Path>]: string };

// { siteId: string }
type x = PathArgs<`/dashboard/sites/:siteId`>

Ideally, if I did something like

const routes: Routes[] = [
  {
    path: `/dashboard/:siteId`,
    prepared: (params) => {...},
    routes: [
      { 
        path: `/dashboard/:siteId/widgets/:widgetId`,
        prepared: (params) => {...}
      },
      {
        path: `/dashboard/:siteId/friend/:friendId`,
        prepared: (params) => {...}
      }
    ]
  }
]

The type of params would automatically be known to be {siteId: string} in the first route, {siteId: string, widgetId: string} for the second route, and {siteId: string, friendId: string} for the second route.

The type declaration I have above for Routes constrains the path and prepare fields correctly for a single object, but does not handle the recursive nature of the config object where it can be a different generic for each nested route, since each path is unique. I'm wondering if this is possible in TypeScript.

Here is a playground with the above code included

1 Answers

The most simplest solution here is to create builder function and create something similar to linked list data structure. Consider this exmaple:

interface Route<Path extends string> {
  path: Path;
  prepare(params: PathArgs<Path>): void;
}

type PathParams<
  Path extends string
  > = Path extends `:${infer Param}/${infer Rest}`
  ? Param | PathParams<Rest>
  : Path extends `:${infer Param}`
  ? Param
  : Path extends `${infer _Prefix}:${infer Rest}`
  ? PathParams<`:${Rest}`>
  : never;

type PathArgs<Path extends string> = { [K in PathParams<Path>]: string };

type ValidRoute = `/${string}/:${string}`

const route = <
  Path extends string,
  Routes extends Route<`${Path}${ValidRoute}`>[]
>(
  path: Path,
  prepare: (param: PathArgs<Path>) => void,
  ...routes: Routes
) => ({
  path,
  prepare,
  routes
})

const routes = <
  Str extends string,
  Elem extends Route<Str>[]
>(...elems: [...Elem]) =>
  elems

const result = [
  route(`/dashboard/:siteId`, (arg) => { },
    route(`/dashboard/:siteId/friend/:friendId`, (arg) => { }),
    route(`/dashboard/:siteId/widgets/:widgetId`, (arg) => { },)
  ),
  route(`/menu/:optioId`, (arg) => { },
    route(`/menu/:optioId/select/:selectId`, (arg) => { })
  )
]

Playground

Arrl arg arguments are infered properly. No need to create complicated recursive data structure. Try to put /menu/:optioId/select/:selectId into dashboard namespace:

const result = [
  route(`/dashboard/:siteId`, (arg) => { },
    route(`/dashboard/:siteId/friend/:friendId`, (arg) => { }),
    route(`/dashboard/:siteId/widgets/:widgetId`, (arg) => { },),
    route(`/menu/:optioId/select/:selectId`, (arg) => { }) // error
  ),
]

You will get an error, because dashboard namespace expects strings with dashboard.

Related