Typescript : recursive template literal type, allow both string and specific chaining

210 Views Asked by At

MCVE

https://stackblitz.com/edit/typescript-s5uy47?file=index.ts%3AL25

(try using the autocomplete on the parameter of the function f to see what it achieves)

I am trying to create a recursive template literal to check if the strings given to a function satisfy the REST approach of my API.

So far, I managed to make simple examples work (in the code, those would be version & health/check).

What I would like to do is allow routes with parameters in it.

In the provided code, the route users/[PARAM] is accepted with no problem, and offered in the autocomplete of the parameter.

But users/12 is not accepted. For me, the issue is at the end of the Dive type :

`${k & DiveKey}/${typeof param | string}`

If I use ${k & DiveKey}/${typeof param} I get the actual result.

If I use ${k & DiveKey}/${string} then it works, but I don't get users/[PARAM] in the autocomplete of the parameter, which "hides" the path from the developer using the function.

My question is : is there a way to keep users/[PARAM] in the autocomplete, and accept users/12 as a parameter ?

Thank you in advance for the response.

Code

export type RestEndpoint = Dive;

const param = '[PARAM]' as const;
type DiveKey = string | number;

type Dive<T = typeof API_REST_STRUCTURE> = keyof {
    [k in keyof T as T[k] extends Record<any, any>
        ? `${k & DiveKey}/${Dive<T[k]> & DiveKey}`
        : T[k] extends typeof param
        ? `${k & DiveKey}/${typeof param | string}`
        : k]: any;
};

const API_REST_STRUCTURE = {
  version: false,
  health: { check: false },
    users: param,
};

function f(p: RestEndpoint) {}

f('version');
f('health/check');
f('users/[PARAM]');
f('users/12');

EDIT 1 : This is what is seen when using ${string} :

enter image description here

And this is what is seen with typeof param (see that users/12 is not accepted)

enter image description here

1

There are 1 best solutions below

0
jcalz On BEST ANSWER

Currently "pattern" template literal types with placeholders like `${string}`, as implemented in microsoft/TypeScript#40598 aren't shown in auto-suggest / auto-complete lists. This is a missing feature of TypeScript, as requested in microsoft/TypeScript#41620. For now, if you want to see suggestions corresponding to such types, you'll need to work around it.


One approach is to make two types: one with "[PARAM]" in it, and one with string in it. Then we make your f() function generic in such a way that the "[PARAM]" version will be suggested, but any string will be accepted in its place.

So let's change Dive so that it only generates the ["PARAM"] version:

type Dive<T = typeof API_REST_STRUCTURE> = keyof {
    [K in keyof T as T[K] extends Record<any, any>
    ? `${K & DiveKey}/${Dive<T[K]> & DiveKey}`
    : T[K] extends typeof param
    ? `${K & DiveKey}/${typeof param}`
    : K]: any;
};

type RestEndpointSchema = Dive;
// type RestEndpointSchema = "version" | "health/check" | "users/[PARAM]"

So RestEndpointSchema has "[PARAM]" in it. And then we can write a utility type to replace "[PARAM]" with string wherever it appears:

type Replace<
    T extends string, S extends string, D extends string,
    A extends string = ""
> = T extends `${infer F}${S}${infer R}` ?
    Replace<R, S, D, `${A}${F}${D}`> : `${A}${T}`

type RestEndpoint = Replace<RestEndpointSchema, typeof param, string>
// type RestEndpoint = "version" | "health/check" | `users/${string}`

That Replace<T, S, D> is a tail-recursive conditional type that takes an input T and produces a version where every appearance of S is replaced with D. So RestEndpoint is Replace<RestEndpointSchema, typeof param, string>.

And now here is the generic function:

function f<T extends string>(
    p: T extends RestEndpoint ? T : RestEndpointSchema
) { }

The type of p is a conditional type that depends on T. When you call f() start typing an input, that input will probably not be a valid RestEndpoint. And therefore the compiler will infer T as something that doesn't extend RestEndpoint, and so the type of p will evaluate to RestEndpointSchema. So you'll see the suggestions including [PARAM]:

f(); // f(p: "version" | "health/check" | "users/[PARAM]"): void
f('')
// ^ health/check
//   users/[PARAM]
//   version

And then when you start typing, it will accept any valid RestEndpoint, including the input starting with "users/" without "[PARAM]" in it:

f('version'); // okay
f('health/check'); // okay
f('users/12'); // okay

but it still rejects invalid inputs:

f('dog'); // error

That's about as close as I can get to your desired behavior. It's a shame that there's no more ergonomic way to do this without generics. The workarounds that work for non-template literal types, as described in microsoft/TypeScript#29729, don't seem to work here. So hopefully microsoft/TypeScript#41620 will eventually be implemented and autosuggestions will include some sort of entries for pattern template literals.

Playground link to code