Search code examples
typescripttypingtemplate-literals

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


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


Solution

  • 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