Skip to content

Intersection type in template literal is not reduced to its bare typeΒ #57918

Description

@unional

πŸ”Ž Search Terms

template literal, intersection type

πŸ•— Version & Regression Information

⏯ Playground Link

https://www.typescriptlang.org/play?#code/C4TwDgpgBAysBOBLAdgcwCrggHgNJQgA9gJkATAZygoRVQD4oBeKfIk8qmpNKAMigoAZhHhQAqgCgAkAH4oAbVwAaCQF0CxUpUXjVuNTPkBvAL6aOO44oAKg5FAAGAEmO5TjtQC4oAV2QA1sgA9gDuDqZGUADkJAC2YAA2AIYkADKIJPDJidEyPtHcdBlZOXnSBUVo5T7IEABuopKSoJBQAEoAjMywtGiYkNhVDJIA9KNQkwB6si1YHQBMPXA8GFhDfaj8UNbJPt2m9GMT07Nzbe0AzMubAzjRyQBGAMbRR+OTUDPn0O0ALDdVndsA8XtFtrt9lBDsdPt8fh0AKyAujAlzGZC+OKPUQed4nL6zVq-ABsKP663RmOxuMcEKgeygB3xcLOUCAA

πŸ’» Code

type StringType<K extends string> = K extends string & infer U
	? [K, U] extends [U, K]
	? {} extends { [P in `${K}`]: unknown }
	? 'templateLiteral'
	: 'stringLiteral'
	: 'string'
	: never

type R1 = StringType<string>
//   ^? type R1 = "string"
type R2 = StringType<string & { a: 1 }>
//   ^? type R2 = "string"

type R3 = StringType<'abc'>
//   ^? type R3 = "stringLiteral"
type R4 = StringType<'abc' & { a: 1 }>
//   ^? type R4 = "templateLiteral" <-- should be "stringLiteral"

type R5 = StringType<`${number}`>
//   ^? type R5 = "templateLiteral"
type R6 = StringType<`${number}` & { a: 1 }>
//   ^? type R6 = "templateLiteral"

πŸ™ Actual behavior

type R = `${'abc' & { a: 1 }}`
// did not reduce => `${'abc' & { a: 1 }}`

πŸ™‚ Expected behavior

type R = `${'abc' & { a: 1 }}`
// should reduce to => `${'abc'}`
// => `abc`

Additional information about the issue

I mentioned this in #54648 after it is closed. It is limiting our ability to write the types that works with string literal and template literal and there is no alternative way to workaround that.
I'm suggesting this issue should be fixed and restore the behavior in 5.0.

Here is my original comment:

This behavior is causing a few types in type-plus to fail (e.g. IsTemplateLiteral, IsStringLiteral, Omit, IsNegative, etc) cyberuni/type-plus#429.

In term of soundness, IMO it does make sense that ${string & { a: 1 }} to be reduced to ${string}.

in JS, it would be:

const extendedStr = Object.assign('abc', { a: 1 })
console.log(`${extendedStr}`) // 'abc'

the reasoning being the toString(): string remains unchanged thus the resulting type should be safe to reduce.

Activity

  1. jcalz commented on Mar 24, 2024

    @jcalz
  2. unional commented on Mar 24, 2024

    @unional
    ContributorAuthor

    No, the tsc process didn't crash.

    Correct. Updated. 🍻

  3. RyanCavanaugh commented on Mar 25, 2024

    @RyanCavanaugh
    Member

    Trying to differentiate template and string literal types seems only possible by relying on weird implementation-detail corner cases and really isn't a great thing to try to offer.

  4. unional commented on Mar 25, 2024

    @unional
    ContributorAuthor

    Trying to differentiate template and string literal types seems only possible by relying on weird implementation-detail corner cases and really isn't a great thing to try to offer.

    It's not just that. This also make it not possible to check for negative number from interaction type.

    Speaking of which, it would be great if TS can provide those type utilities. I understand nowadays TS team don't want to add new type utils, but these are types closely related to the language, and like you said, most of them requires a lot of hackaround ways to get them to work.

  5. HansBrende commented on Jan 29, 2025

    @HansBrende
  6. RyanCavanaugh commented on Jan 29, 2025

    @RyanCavanaugh
    Member

    The Equals type there is (intentionally) reliant on internal compiler implementation details and shouldn't be used unless you're actively trying to break something. Its use in type testing libraries is bad and I would discourage its use.

  7. HansBrende commented on Jan 29, 2025

    @HansBrende
  8. RyanCavanaugh commented on Jan 29, 2025

    @RyanCavanaugh
    Member

    Internal implementation details are subject to change at any time. Equals may evaluate to true but there are no guarantees on this -- it's dependent on whether or not we choose to intern a given type, which is a performance trade-off that may be situationally-dependent.

    The origin of this type is that someone went looking for places where type identity might be unintentionally exposed, was successful, and then introduced this into the wild before we noticed and could un-expose it without breaking a ton of people. TS doesn't have any defined semantics of "identical" types, and attempts to reverse-engineer one are not going to be successful as a result.

  9. HansBrende commented on Jan 29, 2025

    @HansBrende
  10. HansBrende commented on Jan 29, 2025

    @HansBrende
  11. HansBrende commented on Feb 1, 2025

    @HansBrende
  12. HansBrende commented on May 7, 2025

    @HansBrende
    Contributor

    Update on my above comments: I fixed that slightly tangential issue in #61113, which was merged a few days ago (incidentally, also fixing that particular problem with the Equals hack).

    So to re-focus on the issue at hand: personally, I'm ambivalent whether tagged strings should have their tags removed in a template string.

    However, the big issue I do see here is that there is no way to remove tags from a literal if you don't know the exact type of the tag(s) in advance.

    I.e., it is utterly impossible to write an Untag<T> type. For any function you come up with, I can give you a literal that can't be untagged. Here's a good test-case:

    type Evil = 1234 & {a: 0 | 1} & {a: 1 | 2} & {toString: Number['toString']}
    
    type YourFailedAttempt = Untag<Evil> // Expected output: 1234

    This means that libraries that do conditional string manipulation, etc. will necessarily fail if the user passes in any tagged primitive. It would be really nice to have some way of stripping arbitrary tags from an arbitrary primitive literal.

    The very least intrusive fix, I believe, would be to allow the following behavior to work:

    type TheTag = Evil extends number & infer TAG ? TAG : unknown

    Critically, TheTag would need to be everything in Evil except the numeric part. Then we could very simply do: OriginalLiteralType = Evil extends TheTag & infer N ? N : never to retrieve our original literal. (This would have to work for string, symbol, and bigint as well, of course.)

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

    Awaiting More FeedbackThis means we'd like to hear from more people who would be helped by this featureSuggestionAn idea for TypeScript

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions