1

I have declared a type for extracting params from url:

type ExtractParams<Path extends string> = Path extends `${infer Start}(${infer Rest})`
  ? ExtractParams<Start> & Partial<ExtractParams<Rest>>
  : Path extends `${infer Start}/:${infer Param}/${infer Rest}`
  ? ExtractParams<Start> & ExtractParams<Rest> & { [Key in Param]: string }
  : Path extends `${infer Start}/:${infer Param}`
  ? ExtractParams<Start> & { [Key in Param]: string }
  : {};

ExtractParams type in the above code takes in a string path with dynamic route params and converts them in an object having key as route param's name and value as string. If the route param is optional, then the generated object type will also have that key as optional and its value will be string | undefined.

example of using the type:

  type RP1 = ExtractRouteParams<'/courses/:courseId/classes/:classId'>;
  //   ^? { courseId: string; } & { classId: string }
  type RP2 = ExtractRouteParams<'/courses/:courseId/classes(/:classId)'>;
  //   ^? { courseId: string; } & { classId?: string | undefined }

Then I referenced this question, which gave me a utility type to Merge the generated object types intersection, so that it is cleaner to view the generated object type:

type Expand<T> = T extends infer U ? { [K in keyof U]: U[K] } : never;

Using the Expand utility gave me the merged intersection, which makes the type cleaner:

type Params<Path extends string> = Expand<ExtractParams<Path>>;

type X1 = Params<'/courses/:courseId/classes/:classId'>
//   ^? { classId: string; courseId: string }
type X2 = Params<'/courses/:courseId/classes(/:classId)'>
//   ^? { classId?: string | undefined; courseId: string }

In short the code works as expected, when having optional parameters defined in this manner: a(/:b).

I would like to change the above code, so that there should not be too much of repetition in the type declaration

The syntax that is needed is for declaring optional params is only this one: a(/:b). If you could add an add-on in the answer that works for multiple optional param syntaxes as well, that will help in future, but not absolutely required at the moment.

For my use-case, path can have multiple optional parameters but will always be atleast separated by a required parameter. Even if solution allows to have multiple optional parameters one after the other, that does not harm me.

Valid examples of path containing optional params:

'/courses(/:courseId)/classes/:classId' - courseId is optional
'/courses/:courseId/classes(/:classId)' - classId is optional
'/courses(/:courseId)/classes(/:classId)' - courseId and classId both are optional
'/courses(/:courseId)(/:classes)(/:classId)' - If solution includes it, then it does not harm me, but this case is not a requirement.

Invalid examples of path containing optional params, I am 100% sure that my codebase won't be using any of these syntaxes, but If solution is easier, then I do not have any issues as long as I am getting params back.

'(/courses/:courseId)/classes/:classId' - 2 slashes will never be a part of optional params
'/courses(/:courseId/classes)/:classId'

Here is the Playground Link

Vishal
  • 6,238
  • 10
  • 82
  • 158
  • I'm not sure from your description what `a(/:b)` means. Does it mean there will be only a single optional param at the very end? The rule should be explicit or you're leaving it up to interpretation. I'm going to assume that there will be non-nested parentheses (so you'll never see `/:a(/:b(/:c)/:d)/:e`) but that there might be multiple and they might not be at the end. If you want something else, or even if you want that, you should say exactly what it is. – jcalz Apr 09 '23 at 21:02
  • Given that, does [this version](https://tsplay.dev/WPgKKm) meet your needs? If so I'll write it up; if not, what am I missing? – jcalz Apr 09 '23 at 21:02
  • Yes, you are correct, there won't be nested paranthesis, I just consider that path can have multiple optional parameters but will be atleast separated by a required parameter. – Vishal Apr 09 '23 at 21:07
  • Great, please [edit] that into the question, and maybe give a few examples of different valid uses (and maybe invalid uses) – jcalz Apr 09 '23 at 21:09
  • Thanks for the code. I will do that very soon, please post your solution as an answer as it looks to work for the test cases I have. – Vishal Apr 09 '23 at 21:10
  • I have updated the question with appropriate requirements. Please post the answer when you get some time. I am eager to understand the `Params` type in your solution. :) – Vishal Apr 09 '23 at 21:21
  • I've tried to break it apart into logical pieces and then combined them back together at the end – jcalz Apr 09 '23 at 21:46

1 Answers1

1

We can write a utility type to parse a string literal type into its pieces found outside and inside parentheses. We could call this ReqandOptPiects<T> which returns an object type like {Rq: ⋯, Op: ⋯} where Rq is the union of the chunks of the string that are outside parentheses and Op is the union of the chunks that are inside parentheses:

type ReqAndOptPieces<T extends string,
  Rq extends string = never, Op extends string = never>
  = T extends `${infer L}(${infer M})${infer R}` ?
  ReqAndOptPieces<R, Rq | L, Op | M> :
  { Rq: Rq | T, Op: Op }

That's a tail recursive conditional type using template literal types to split on parentheses and grab things inside and out. Examples:

type X1a = ReqAndOptPieces<'/courses/:courseId/classes/:classId'>
/* type X1a = {
    Rq: "/courses/:courseId/classes/:classId";
    Op: never;
} */
type X2a = ReqAndOptPieces<'/courses/:courseId/classes(/:classId)'>
/* type X2a = {
    Rq: "" | "/courses/:courseId/classes";
    Op: "/:classId";
} */
type X3a = ReqAndOptPieces<'/a/:b(/c/:d/e)/f/:g/(:h/:i)/:j(/k)/l/:m'>
/* type X3a = {
    Rq: "/a/:b" | "/f/:g/" | "/:j" | "/l/:m";
    Op: "/c/:d/e" | ":h/:i" | "/k";
} */

Given those pieces, we can take each piece and split them into their path segments, by writing a PathSegments<T> utility type:

type PathSegments<T extends string, A extends string = never> =
  T extends `${infer L}/${infer R}` ? PathSegments<R, A | L> : A | T;

which works as shown:

type Example = PathSegments<"a/bc/def/ghij/klmn/o">
// type Example = "a" | "bc" | "def" | "ghij" | "klmn" | "o"

Let's combine those:

type ReqAndOptSegments<T extends string> = ReqAndOptPieces<T> extends 
  { Rq: infer Rq extends string, Op: infer Op extends string } ?
  { Rq: PathSegments<Rq>, Op: PathSegments<Op> } : never;

type X1b = ReqAndOptSegments<'/courses/:courseId/classes/:classId'>
/* type X1b = {
    Rq: "" | "courses" | ":courseId" | "classes" | ":classId";
    Op: never;
} */
type X2b = ReqAndOptSegments<'/courses/:courseId/classes(/:classId)'>
/* type X2b = {
    Rq: "" | "courses" | ":courseId" | "classes";
    Op: "" | ":classId";
} */
type X3b = ReqAndOptSegments<'/a/:b(/c/:d/e)/f/:g/(:h/:i)/:j(/k)/l/:m'>
/* type X3b = {
    Rq: "" | "a" | ":b" | "f" | ":g" | ":j" | "l" | ":m";
    Op: "" | "c" | ":d" | "e" | ":h" | ":i" | "k";
} */

We're getting closer... let's filter those so that we only allow those strings that begin with ":" and parse them to remove that ":". This can be done in one operation via

type SegmentToParam<T extends string> = T extends `:${infer P}` ? P : never;
type Example2 = SegmentToParam<":abc" | "def" | ":ghi">
// type Example2 = "abc" | "ghi"

So we can combine all of those to get

type ReqAndOptParams<T extends string> = ReqAndOptPieces<T> extends 
  { Rq: infer Rq extends string, Op: infer Op extends string } ?
  { Rq: SegmentToParam<PathSegments<Rq>>, Op: SegmentToParam<PathSegments<Op>> } : never;

type X1c = ReqAndOptParams<'/courses/:courseId/classes/:classId'>
/* type X1c = {
    Rq: "courseId" | "classId";
    Op: never;
} */
type X2c = ReqAndOptParams<'/courses/:courseId/classes(/:classId)'>
/* type X2c = {
    Rq: "courseId";
    Op: "classId";
} */
type X3c = ReqAndOptParams<'/a/:b(/c/:d/e)/f/:g/(:h/:i)/:j(/k)/l/:m'>
/* type X3c = {
    Rq: "b" | "g" | "j" | "m";
    Op: "d" | "h" | "i";
} */

So, we want to use those as required/optional keys for our output. So let's write another utility type:

type ReqAndOptToObj<Rq extends string, Op extends string> =
  { [K in keyof (Record<Rq, 0> & Partial<Record<Op, 0>>)]: string }

type Example3 = ReqAndOptToObj<"a" | "b", "c" | "d">
/* type Example3 = {
    a: string;
    b: string;
    c?: string | undefined;
    d?: string | undefined;
} */

The way that works is to combine the required and optional keys via the Record and the Partial utility types and an intersection, and then do one outer mapped type to create a single object type from it.


And so finally we can combine all of that into a single Params utility type:

type Params<T extends string,
  Rq extends string = never, Op extends string = never>
  = T extends `${infer L}(${infer M})${infer R}` ? Params<R, Rq | L, Op | M> :
  { [K in keyof (
    Record<SegmentToParam<PathSegments<Rq | T>>, 0> &
    Partial<Record<SegmentToParam<PathSegments<Op>>, 0>>
  )]: string }

And depending on what we want to do, we could inline as much or as little of that as we'd like. And when we test it:

type X1 = Params<'/courses/:courseId/classes/:classId'>
/* type X1 = {
    courseId: string;
    classId: string;
} */

type X2 = Params<'/courses/:courseId/classes(/:classId)'>
/* type X2 = {
    courseId: string;
    classId?: string | undefined;
} */

type X3 = Params<'/a/:b(/c/:d/e)/f/:g/(:h/:i)/:j(/k)/l/:m'>
/* type Test = {
    m: string;
    b: string;
    g: string;
    j: string;
    d?: string | undefined;
    h?: string | undefined;
    i?: string | undefined;
} */

That looks like what you want!

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360
  • Great answer, understood everything well except one part. Can you please explain what is the meaning of `0` in `Record`? – Vishal Apr 10 '23 at 07:23
  • It happens to be a numeric literal type, but anything could have been used there at all because it's ignored. The intermediate type `Record & Partial` might look like `{rq: 0} & {op?: 0}` and then mapping over it with `{[K in keyof ⋯]: string}` gives `{rq: string, op?: string}`. The `0` was just a dummy type and it's the shortest one I can think of to write. – jcalz Apr 10 '23 at 13:21
  • Thanks, that explanation helps me to understand the full answer. – Vishal Apr 10 '23 at 20:47