Search code examples
typescripttemplate-literals

TypeScript infer template literal from const template literal


I trying to type a REST API using fancy TypeScript template literals. I have a system that works for const string types and const Javascript template literal types, but not for Javascript template literals with unknown values.

Consider the following recursive path parser.

type PathVariable = string;
type ExtractPathVariable<T extends string> = T extends `:${string}`
    ? PathVariable
    : T;
type PathParts<Path extends string> = Path extends `/${infer Rest}`
    ? PathParts<Rest>
    : Path extends `${infer Start}/${infer Rest}`
    ? [ExtractPathVariable<Start>, ...PathParts<`${Rest}`>]
    : Path extends `${infer Item}`
    ? [ExtractPathVariable<Item>]
    : never;

It gives expected results for some cases

// A == ["short", "url", "path"]
type A = PathParts<"/short/url/path">;
const b = `/short/url/path` as const;
// B == ["short", "url", "path"]
type B = PathParts<typeof b>;
// C == ["short", "${y}", "path"]
type C = PathParts<"/short/${y}/path">;
const d = `/short/${45}/path` as const;
// D == ["short", "45", "path"]
type D = PathParts<typeof d>;

However, the case I'm most interested in (because that's how I call my API), it doesn't work.

let y: unknown;
const e = `/short/${y}/path`;
// E == never
type E = PathParts<typeof e>;

Is there a way to make PathParts<typeof e> work? A result of E == ["short", string, "path"] or E == ["short", unknown, "path"] would be fine.


Solution

  • UPDATE FOR TypeScript 4.3:

    Issue microsoft/Typescript#43060 has been marked fixed by pull request microsoft/Typescript#43361, which should be released with TypeScript 4.3. At that point, your code above will work (as long as you use a const assertion as I mention below):

    let y: unknown;
    const e = `/short/${y}/path` as const;
    // const e: `/short/${string}/path`
    type E = PathParts<typeof e>;
    // type E = ["short", string, "path"]
    

    You can see it in action here: Playground link to code


    Previous answer for TS 4.2:

    Sorry, but I think this is currently not possible as of TypeScript 4.2.

    First, to even get close to this behavior you'd need to use a const assertion to tell the compiler that you'd like e to be inferred as a template literal type and not just string. While there is a consistency argument that, like const foo = "abc" is inferred as "abc", so const bar = `abc${x}` should be inferred as type `abc${string}` or the like, as requested in microsoft/TypeScript#41631, this change ended up breaking too much real world code. So you need the as const:

    const e = `/short/${y}/path` as const
    // const e: `/short/${string}/path`
    

    But that's about as far as we can go. You're trying to match a template literal type with multiple infer locations against another template literal type with a "pattern" literal like `${number}` (where pattern literals are implemented in microsoft/TypeScript#40598. But according to microsoft/TypeScript#43060 this is not possible (it mentions `${number}` instead of `${string}, but the same situation occurs for all pattern literals):

    type Simple<T> = T extends `${infer F}/${infer L}` ? [F, L] : never
    type Works = Simple<`foo/bar`> // ["foo", "bar"];
    type Broken = Simple<`foo/${string}`> // never
    type AlsoBroken = Simple<`${string}/bar`> // never
    

    That issue hasn't been classified as a bug/limitation/suggestion yet, but it is related to microsoft/TypeScript#43243 which is considered a suggestion. For now there doesn't seem to be a mechanism in place to allow this sort of inference to work. I haven't been able to find any workaround that behaves reasonably, either.

    If you care about seeing this happen you might want to go to either of those issues and give a 👍 and/or describe why your use case is compelling.

    Playground link to code