Skip to content

Template literal type inference failure due to lazy placeholder matching #49839

Open

Description

@jcalz

Bug Report

🔎 Search Terms

template literal types, placeholders, pattern, inference, lazy/non-greedy

🕗 Version & Regression Information

  • This is the behavior in every version I tried after template literal types were introduced in TS4.1, and I reviewed the FAQ for entries about template literal types

⏯ Playground Link

Playground link with relevant code

💻 Code

declare function f<T extends "x" | "y">(a: `${string}.${T}`): T;
const x = f("abc.x") // "x"
const y = f("def.y") // "y"
const z = f("ghi.jkl.x") // "x" | "y" !!!

🙁 Actual behavior

In the third call, T is inferred as "x" | "y"

🙂 Expected behavior

T should be inferred as "x"


This came from this Stack Overflow question. It looks like the type `${string}.${T}` when compared to "ghi.jkl.x" lazily matches `${string}` as "ghi", so that it produces "jkl.x" as an inference candidate for T. This fails to match the "x" | "y" constraint. So that's an invalid inference candidate.

And I guess inference just fails, so T falls back to the constraint. Sure enough "ghi.jkl.x" does match `${string}.x` | `${string}.y`, so there's no error, but it's no longer useful as a generic call.

Just wondering where this falls on the spectrum from "bug" to "intentional".

(Note that this situation isn't quite the same as two immediately adjacent placeholders as in #46124 or #47048 or #49411.)

(Also note that my workaround for this would be to write a recursive conditional type to actually find the last delimiter, as shown here, but it's yucky.)

Activity

  1. RyanCavanaugh commented on Jul 11, 2022

    @RyanCavanaugh
    Member

    This is pure speculation but I'm guessing what happens is:

    • We try to do an inference from "ghi.jkl.x" to ${string}.${T} and collect a lone candidate jkl.x, since this is a greedy operation
    • This doesn't fit the constraint x | y, so we fall back to the constraint. Inference has effectively failed at this point; under most circumstances this would result in an error on the call, though strictly speaking this is absolutely not an error condition
    • We evaluate "ghi.jkl.x" against ${string}.x | ${string}.y and succeed, since now we're using a back-anchored method

    It seems like in the first step we need to be applying the information about the constraint to the string-matcher-inference algorithm

  2. added
    Cursed?It's likely this is extremely difficult to fix without making something else much, much worse
    on Jul 11, 2022
  3. jcalz commented on Jul 11, 2022

    @jcalz
    ContributorAuthor

    I love the $\mathfrak{Cursed?}$ tag so much

  4. RyanCavanaugh commented on Jul 11, 2022

    @RyanCavanaugh
    Member

    Anders Hejlsberg (@ahejlsberg) maybe you would enjoy looking at this if you want

  5. Andarist commented on Feb 1, 2023

    @Andarist
    Contributor

    I believe that the issue found by Andrea Simone Costa (@jfet97) is essentially the same as the one reported here (or at least - it fails due to the same implementation details):

    type Exactly5<T extends string> = T extends `${any}${any}${any}${any}${any}${infer Last extends ""}` ? T : never
    
    type test01 = Exactly5<""> 
    //   ^? type test01 = never
    type test11 = Exactly5<"a"> 
    //   ^? type test11 = never
    type test21 = Exactly5<"ab"> 
    //   ^? type test21 = never
    type test31 = Exactly5<"abc"> 
    //   ^? type test31 = never
    type test41 = Exactly5<"abcd"> 
    //   ^? type test41 = never
    type test51 = Exactly5<"abcde">  
    //   ^? type test51 = "abcde"
    type test61 = Exactly5<"abcdef"> // should be never
    //   ^? type test61 = "abcdef"

    It can be tested out in this TS playground.

    What I've seen in the compiler when debugging this looks very close to what Ryan Cavanaugh (@RyanCavanaugh) has described here.

    1. the inferred candidate doesn't match the constraint
    2. so it's replaced with the constraint here
    3. since the constraint is an empty string, it then satisfies the final check

    It seems like in the first step we need to be applying the information about the constraint to the string-matcher-inference algorithm

    Could you elaborate on this one? I'm not sure if I understand this. I would assume that this should somehow be handled after the step 1.

  6. RyanCavanaugh commented on Feb 3, 2023

    @RyanCavanaugh
    Member

    Could you elaborate on this one?

    Right now the algorithm to infer to a template string doesn't "know" that it's trying to match against a type parameter with a constraint, so it doesn't realize that the inferences it's collecting are effectively possibly garbage. What we "should" do in this call

    declare function f<T extends "x" | "y">(a: `${string}.${T}`): T;
    const z = f("ghi.jkl.x")

    is do something effectively the same (but more efficient) as inferring to "ghi.jkl.x" to ${string}.x | ${string}.y, seeing which if any generated successful matches, and recording those as inference candidates (in this case, collecting a lone candidate "x" due to the successful match on the first one). That'd produce the expected result.

  7. Autumn-one commented on Aug 18, 2023

    @Autumn-one

    The details of the template string implementation really confuse us in many scenarios, and maybe it needs time to improve

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    BugA bug in TypeScriptCursed?It's likely this is extremely difficult to fix without making something else much, much worseDomain: check: Type InferenceRelated to type inference performed during signature resolution or `infer` type resolutionHelp WantedYou can do this

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions