Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
jcalz opened this issue Jul 8, 2022 · 6 comments
Open
Labels
Bug A bug in TypeScript Cursed? It's likely this is extremely difficult to fix without making something else much, much worse Help Wanted You can do this
Milestone

Comments

@jcalz
Copy link
Contributor

jcalz commented Jul 8, 2022

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.)

@RyanCavanaugh RyanCavanaugh added the Bug A bug in TypeScript label Jul 11, 2022
@RyanCavanaugh
Copy link
Member

RyanCavanaugh commented Jul 11, 2022

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

@RyanCavanaugh RyanCavanaugh added Help Wanted You can do this Cursed? It's likely this is extremely difficult to fix without making something else much, much worse labels Jul 11, 2022
@RyanCavanaugh RyanCavanaugh added this to the Backlog milestone Jul 11, 2022
@jcalz
Copy link
Contributor Author

jcalz commented Jul 11, 2022

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

@RyanCavanaugh
Copy link
Member

RyanCavanaugh commented Jul 11, 2022

@ahejlsberg maybe you would enjoy looking at this if you want

@Andarist
Copy link
Contributor

Andarist commented Feb 1, 2023

I believe that the issue found by @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 @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.

@RyanCavanaugh
Copy link
Member

RyanCavanaugh commented Feb 3, 2023

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.

@Autumn-one
Copy link

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
Labels
Bug A bug in TypeScript Cursed? It's likely this is extremely difficult to fix without making something else much, much worse Help Wanted You can do this
Projects
None yet
Development

No branches or pull requests

4 participants