Skip to content

${string}${string} turns into stringΒ #57233

Closed as not planned
Closed as not planned

Description

@nomadshiba

πŸ”Ž Search Terms

"string"

πŸ•— Version & Regression Information

  • This is an unexpected behavior

⏯ Playground Link

Playground

πŸ’» Code

// gives `true`, because `${string}${string}` type turns into string type but it shouldnt and should give `false`
type _ = string extends `${string}${string}` ? true : false;

// Similarly should be false because first ${string} gets the empty space only empty space, and there is nothing left to satify second ${string}
type __ = "" extends `${string}${string}` ? true : false;

πŸ™ Actual behavior

`${string}${string}` type turns into string type.

πŸ™‚ Expected behavior

`${string}${string}` should stay as its.

Additional information about the issue

// gives `true`, because `${string}${string}` type turns into string type but it shouldnt and should give `false`
type _ = string extends `${string}${string}` ? true : false;

// Similarly should be false because first ${string} gets the empty space only empty space, and there is nothing left to satify second ${string}
type __ = "" extends `${string}${string}` ? true : false;

// What is correct
{
  // works without error as expected because empty space at the start satisfies ${string}
  const foo: `${string}@` = "@";

  // gives error as expected because first ${string} already uses the empty space at the start
  //  and second one has no empty space to use
  //  so before @ it expects at least one other character
  const bar: `${string}${string}@` = "@";

  // so this works as expected
  const baz: `${string}${string}@` = "a@";
}

// What is wrong
{
  // should give error but doesnt because `${string}${string}` type turns into string type
  //  `${string}${string}` should stay as its and foo should expect its setter value to satisfy at least one character
  const foo: `${string}${string}` = "";
}

Not sure if its a feature or a bug, but and its not consistent, and it looks like a bug, sound like a bug, acts like a bug.

Activity

  1. Andarist commented on Jan 30, 2024

    @Andarist
    Contributor

    This has been explicitly changed in #41693 so it's intentional. I find the behavior slightly surprising though. This doesn't happen in an inference context but it feels like this (from the original PR that introduced template literal types should apply here):

    A placeholder immediately followed by another placeholder is matched by inferring a single character from the source.

    IIUC, this would make your interpretation correct.

  2. Josh-Cena commented on Jan 30, 2024

    @Josh-Cena
    Contributor

    A string type can match the empty string (like .*), so the two types are indeed equivalent. There is no type for "non-empty string".

  3. fatcerberus commented on Jan 30, 2024

    @fatcerberus

    Mateusz BurzyΕ„ski (@Andarist)

    A placeholder immediately followed by another placeholder is matched by inferring a single character from the source.

    Yes, but that also applies to e.g. `${string}${"bar" | "d" | "manchu"}` which is matched by "foobar", "food", and "foomanchu" but good luck extracting the foo part later using the same pattern. So the inconsistency there is hardly exclusive to `${string}${string}`. Joshua Chen (@Josh-Cena)'s reading of the situation seems more correct.

  4. nomadshiba commented on Jan 30, 2024

    @nomadshiba
    Author

    Joshua Chen (@Josh-Cena)
    ${string} is same as string, that's true, but ${string}${string} is not same as string.
    Because and empty string can't mapped on to ${string}${string} only reason it does is ${string}${string} is wrongly being simplified into string.
    If you:

    `abc` extends `${infer A}${infer B}`

    A is gonna get "a" and B is gonna get "bc".

    Similarly if you:

    `a` extends `${infer A}${infer B}`

    A is gonna get "a" and B is gonna get "", if we also had C it wouldn't work because B already got the empty string "", so there is nothing left for the C.
    Same thing for if the literal was empty string "" instead of "a", then A would get the empty string "", and there would be nothing left for the B nothing, not even an empty string.

    For example right now we can do types like this:

    type CustomTag = `${string}${string}-${string}${string}`

    which would expect at least one character before and after "-".
    Why this happens?

    So if we try to map "-" on CustomTag, it would look like this:

    • ${string} : "-"
    • ${string} : ""
    • "-" : not-matching because never doesn't satisfy "-"
    • ${string} : never
    • ${string} : never
      So doesn't work.

    But if we try to map "a-a" on CustomTag, it would look like this:

    • ${string} : "a"
    • ${string} : ""
    • "-" : "-"
    • ${string} : "a"
    • ${string} : ""
      So it works.

    Similarly if we try to map "foo-bar" on CustomTag, it would look like this:

    • ${string} : "f"
    • ${string} : "oo"
    • "-" : "-"
    • ${string} : "b"
    • ${string} : "ar"
      Which again works.

    So again, ${string} is same as string, that's true, but ${string}${string} is not same as string.
    Only reason it acts like they are the same is because its hard-coded to be simplified into string, it seems based on the PR Mateusz BurzyΕ„ski (@Andarist) shared.

    Also, Bruce Pascoe (@fatcerberus) you can extract "foo", am I missing something?

    type ExtractPrefix<T> = T extends `${infer U}${"bar" | "d" | "manchu"}` ? U : never
    type Foo = ExtractPrefix<'food' | 'foobar' | 'foomanchu'> // gives "foo"

    isn't this ${infer U}${"bar" | "d" | "manchu"} the same pattern?

    Anyway, it doesn't matter, just realized the work around the weird behavior.
    Just do ${infer A}${infer B} instead of ${string}${string}. It seems only ${string}${string} is hard-coded to be string.
    Here:

    type _ = string extends `${infer A}${infer B}` ? true : false; // gives false as it should
    type _ = "" extends `${infer A}${infer B}` ? true : false; // again gives false as it should

    Works perfectly.

  5. Josh-Cena commented on Jan 31, 2024

    @Josh-Cena
    Contributor

    DeepDoge A ${string} in a template literal acts like .* in a regex: it can consume from 0 to infinite characters. Therefore `${string}${string}` is able to match the empty string simply by both of them matching the empty string, because "" + "" === "".

    You example with extends infer is simply because TS "tries to be helpful". You would likely not want any placeholder to match the empty string. However TS picking any substring would be legal behavior.

  6. nomadshiba commented on Feb 2, 2024

    @nomadshiba
    Author

    Joshua Chen (@Josh-Cena)

    A ${string} in a template literal acts like .* in a regex

    That's just wrong.

    Playground

    type CustomTag = `${string}${string}-${string}${string}`
    function isCustomTag(value: string) {
        return /.*.*-.*.*/.test(value)
    }
    
    const value = "-"
    
    type _ = typeof value extends CustomTag ? true : false // Gives false
    console.log(isCustomTag(value)) // Returns true
    
    const someCustomTag: CustomTag = value // Type '"-"' is not assignable to type '`${string}${string}-${string}${string}`'

    Also, infer is not supposed to change the type, its just suppose to extract part of a type. I can switch any type i want with an infer keyword and its not supposed to change the actual type.
    Again, ${string} is equal to string that's true, but ${string}${string} is not equal to string. Only reason it acts like that, is, it's being simplified into string wrongly, while string is not simplified version of ${string}${string}, they are different.
    As I showed in the example above, first ${string} gets the empty part, and there is never left for the second ${string}, so it doesn't match.
    image

  7. snarbies commented on Feb 2, 2024

    @snarbies

    Is there precise documentation outlining exactly how inference and interpolations behave? I can't seem to find it. Looking around the web, I see some other similar problems where the solution is to use what appears to be equivalent with the explanation of "I don't know why this works but it does." E.g. ${any}${any} behaves differently from ${string}${string}

  8. fatcerberus commented on Feb 2, 2024

    @fatcerberus

    DeepDoge You're talking about the behavior of infer in a template literal, which is entirely separate from the concern of what's assignable to the type1. An empty string would still be assignable to `${string}${string}` even if it weren't simplified. Likewise, "foo123" is assignable to `${string}${number}`, but you won't be able to extract the "foo" part using that pattern later, because TS is hardcoded to only consume one character at a time for the first placeholder.

    Footnotes

    1. Assignability is the primary primitive in the type system and is generally the thing that determines which simplifications the compiler is allowed to do. This is how you get things like subtype reduction, etc. ↩

  9. nomadshiba commented on Feb 2, 2024

    @nomadshiba
    Author

    Bruce Pascoe (@fatcerberus) I didn't only show examples with infer. Again if you read examples i showed above and below:

    Playground

    "Hello_World" satisfies `Hello_${string}${string}` // Pass
    "Hello_W" satisfies `Hello_${string}${string}` // Pass
    "Hello_" satisfies `Hello_${string}${string}` // Error
    
    "my-button" satisfies `${string}${string}-${string}${string}` // Pass 
    "-" satisfies `${string}${string}-${string}${string}` // Error 
    "-" satisfies `${string}-${string}` // Pass
    
    "A" satisfies `${any}${any}` // Pass
    "" satisfies `${any}${any}` // Error
    
    "" satisfies `${string}${string}` // Pass!?!?! Why? Because simplified to string for no reason, broken math, broken grouping, broken type, bug 

    TypeScript uses the group theory, not regex.

    Tbh, I'm not even gonna try, ${any}${any} behaves correctly because they forgot to break it. So that's enough for me.
    ${string}${string} broken? broken things are everywhere. ${any}${any} works. Just avoid ${string} simple.
    I don't care. It works, it works. Have a nice day everyone.

  10. Josh-Cena commented on Feb 2, 2024

    @Josh-Cena
    Contributor

    I think `${string}${string}` not being equivalent to `${string}` in your cases above is a bug...

  11. nomadshiba commented on Feb 2, 2024

    @nomadshiba
    Author

    Oh Really? 2 + 2 = 2, silly me. Didn't know.

  12. snarbies commented on Feb 2, 2024

    @snarbies

    This would be more like 0 + 0 = 0.

    Conceptually, ${string}${string} should collapse to just string. ${string} includes an empty string, and two of those combined is still an empty string.

    It's true that ${string}{infer U} yields never, but there's nothing in the documentation that states or implies that ${string}${string}, or even ${any}${any}, can't or shouldn't be simplified to string. If you rely on the latter you might be depending on unspecified (potentially subject to change) behavior.

    This is why I was asking if there was any documentation on the specific behavior here. The typescript docs don't exactly read like a specification.

  13. nomadshiba commented on Feb 2, 2024

    @nomadshiba
    Author

    snarbies
    never is 0, string is a valid type with the length field that has the number type that is between 0 and Infinity,
    never & number = never because 0 * 123 = 0
    never | number = number because 0 + 123 = 123
    any | number = number because Infinity + 123 = Infinity
    but that's not even the point, these are just analogies, and i was just mocking.

    i dont maintain typescript, you can make it work any way you want.
    i was showing that, expected behavior of template literals is:
    A template string type has multiple types, separated with ${}
    Like a for loop we go through every char that matches current type and get it as its part until:

    • current type satisfied without any fails
    • and until next part matches to the next type after current type is satisfied, if there is a next type.
    • if there is next type but no next part then it fails. since removing empty string from empty string is never.

    and its not suppose to work differently than inferred version anyway.
    this is how it works everywhere ${string}${string} alone or longer is just an exception.

    • im not complaining about adding two const numbers to each other gives you just number
    • or there is no clear way to differentiate between what type a mutable variable returns and what type it accepts while setting it.
    • or return type of a function is decided on based on its definition and not based on where you call it from with what arguments, so you have to repeat your runtime logic with types if its in a function.
    • or not being able define ranges like 0..255 & number or 0..255 & bigint
    • make ${string} act like .*.

    even if there are no docs i can find, ${string}${string} behavior is the exception to the general rule of how it works currently everywhere. if this is a "bug" for you all. cool, break it.

  14. snarbies commented on Feb 2, 2024

    @snarbies

    As said, infer is supposed to work differently from substitutablility (i.e. type compatibility). And to put it in structural terms since TypeScript is structural...

    Playground link

    type char = {__brand: "Hey look, I'm a character!"}
    type PseudoString = char[]
    
    // Simplifies to `char[]`
    type MultiString = [...PseudoString, ...PseudoString];
    //   ^?
    
    // Evaluates to `true`
    type Collapses = PseudoString extends MultiString ? true : false;
    //   ^?

    So I'm not really clear on how you're interpreting this as an exception. Structurally, ${string}${string} is exactly equivalent to string.

  15. nomadshiba commented on Feb 2, 2024

    @nomadshiba
    Author

    snarbies
    TypeScript doesn't create tuples types correctly already, simplifies it into an array.

    Playground

    declare const CHAR: unique symbol
    type char<T extends string = string> = { [CHAR]: T }
    function char<const T extends string>(value: T extends `${any}${any}${any}` ? never : T): char<T> {
        if (value.length !== 1) throw 0
        return value as never
    }
    type str = char[]
    
    {
        type Tuple = [char<'('>, ...str, char<'-'>, ...str, char<')'>]
        //    ^?
        //       [char<"(">, ...(char<string> | char<"-">)[], char<")">]
        const tuple: Tuple = [char('('), char('a'), char(')')] // Matches, while it shouldn't
    }
    
    {
        type Pattern = `(${string}-${string})`
        const pattern: Pattern = "(a)" // Error
        const pattern2: Pattern = "(a-a)" // Matches
        const pattern3: Pattern = "(-)" // Matches
    }

    And I was talking about ${string}${string} being an exception to how rest of the template literal type behaves.
    As I showed here: #57233 (comment)

    • prefix${string}${string} or
    • ${string}${string}suffix or
    • ${string}${string}middle${string}${string} or
    • ${any}${any} or
    • ${infer A}${infer B},
    • etc.
      it always means at least one character, except for ${string}${string} alone, that's the exception.
      ${string}${string} always means at least one character, except if its alone.
  16. snarbies commented on Feb 2, 2024

    @snarbies

    And I was talking about ${string}${string} being an exception to how rest of the template literal type behaves.

    Ah, gotcha. You're right, that's definitely inconsistent. Unfortunately, that all appears to be unspecified behavior.

    It seems like you're operating on the premise that if you start with an empty set and remove zero items, the remainder is never, and most of the behavior with tuples seems to validate that premise. To me that premise seems faulty on its face, but in practical terms the biggest issue is the inconsistency. If it could all go one way or the other, we could easily collectively shrug and move on with our lives.

  17. typescript-bot commented on Feb 5, 2024

    @typescript-bot
    Contributor

    This issue has been marked as "Not a Defect" and has seen no recent activity. It has been automatically closed for house-keeping purposes.

  18. locked as resolved and limited conversation to collaborators on Oct 22, 2025
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

    Not a DefectThis behavior is one of several equally-correct options

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions