Skip to content

Inferring a key within a string inconsistently yields too wide a union #57126

Closed as not planned

Description

@ghostinpeace

🔎 Search Terms

infer, keyof, string, interpolation, union, all keys, too big, too wide

🕗 Version & Regression Information

Syntax not supported before 4.7
Similar behavior on all versions upwards

⏯ Playground Link

https://www.typescriptlang.org/play?ts=5.3.3&ssl=6&ssc=1&pln=21&pc=47#code/PTAEEkDsDMFMCd4EtIHNQENQGtYE9QB3JAFwAsVNQBnE5NUFAYwHtJqlbZISAbAvEli8AJtVAkWLIkhGwqAV0hI2AWABQGkDTw8MAD1CQWJGgoAO5lvBKwRoAEaxo1+QBYAdAHYPbrWAcFdGh4FgBbCTIEeTVNdRI8c3kAEQAGUABeUABvDVBMAC5QAHJU4oAaPMci4oBGCqqmGoAmYo0AXwBuDQ0EpNBk2sycqowMGrLK9XyHGvqp-KaS1o7uuL75ABVYWgAeZIwSDHLQTYA+Yc3QWH1bSDFQAAMAEmyUOHhQAGlr2+4H3B4FjQAaHLAAMhodBQqHar1o9Fhj1AAH5vqAitAMLxqLA1r1Elt0lltns0idihgABo04pnTr5fLaSlteKE05DEk7Ej7WoUsY0ql0hlMsDFBzFAA+xSYUspGFZQA

💻 Code

type D0 = {
  a: '0',
  b: '1',
  c: '2'
};

type D1 = {
  aa: '0',
  b: '1',
  c: '2'
};

type Test<Data, T> = T extends `${infer K extends keyof Data & string}${string}` ? K : false;

type T0 = Test<D0, 'aXXX'>;    // 'a'
type T1 = Test<D1, 'aaXXX'>;   // 'b'|'c'|'aa'

🙁 Actual behavior

T1 is the union of all keys of D1 while only one fits within the pattern.
The extra keys are irrelevant.
The inference is not helpful.

This behavior seems inconsistent since T0 gives the expected key.

🙂 Expected behavior

T1 = 'aa'

Additional information about the issue

No response

Activity

  1. Andarist commented on Jan 22, 2024

    @Andarist
    Contributor

    This is a somewhat confusing behavior but it works as expected.

    The algorithm first infers something - a single character: 'a'. And only after that it checks if this satisfies the constraint. It doesn't satisfy the constraint here so the inferred type gets replaced by the constraint and that is used for your K. So you essentially end up with this once we substitute all of your type arguments etc:

    type WithConstraintUsed = 'aaXXX' extends `${keyof D1 & string}${string}` ? keyof D1 & string : false
    //       ^? type WithConstraintUsed = "aa" | "b" | "c"

    This is quite confusing because it makes the more concise/intuitive variant worse than the verbose one (one that behaves like you have expected):

    type Test<Data, T> = T extends `${infer K}${string}`
      ? K extends keyof Data & string
        ? K
        : false
      : false;
  2. RyanCavanaugh commented on Jan 22, 2024

    @RyanCavanaugh
    Member

    This is effectively the same as #49839 - the constraint of type parameters, including those introduced by infer, doesn't change the matching algorithm

  3. Andarist commented on Jan 23, 2024

    @Andarist
    Contributor

    the constraint of type parameters, including those introduced by infer, doesn't change the matching algorithm

    Is this true just for string matching? Or is it a general rule? I remember I landed a pull request that introduced an implied arity based on those constraints for some variadic elements when inferring from tuples. This has been a while ago - but should such a change be reverted? Or is the context there sufficiently different that this implied arity is OK there? Note that I realized later that this change wasn't perfect - it only handles some cases (it doesn't make other cases worse though).

  4. ghostinpeace commented on Jan 23, 2024

    @ghostinpeace
    Author

    Thank you for explaining.

    Mateusz Burzyński (@Andarist): I was about to get even more confused imagining the option you mentioned would work the way I'd been expecting. Fortunately (I guess?), it doesn't. K is still inferred to be the first character available, and the type evaluates to false. There might have been another misunderstanding somewhere...

    Good news is that this provides a way to parse a string one character at a time.
    I would also be curious to know what happens when matching the empty string against `${infer Ch}${string}`, since it fails (while technically, both parameters could be instantiated with the empty string).

  5. Andarist commented on Jan 23, 2024

    @Andarist
    Contributor

    Ah yes - sorry. Sorry for the confusion - as you mentioned, the proposed version is not good since infer consumes a single character.

    Good news is that this provides a way to parse a string one character at a time.

    I recommend doing this most of the time.

    I would also be curious to know what happens when matching the empty string against ${infer Ch}${string}, since it fails ( while technically, both parameters could technically be instantiated with the empty string).

    Like here?

    type Test<T extends string> = T extends `${infer Char}${string}` ? Char : never;
    type Result = Test<"">; // never

    Or did you mean something closer to this?

    type D2 = {
      "": "empty";
    };
    
    type Test<Data, T> = T extends `${infer K extends keyof Data & string}${string}`
      ? K
      : false;
    
    type T2 = Test<D2, "">;
  6. ghostinpeace commented on Jan 23, 2024

    @ghostinpeace
    Author

    I had the first example in my mind.
    That said, I would guess it's the same logic behind both, since the second only introduces a constraint on the type to be inferred (extends ''), which is not to influence the matching process.

  7. Andarist commented on Jan 24, 2024

    @Andarist
    Contributor

    Yeah, this isn't that different. From #40336 :

    Type inference supports inferring from a string literal type to a template literal type. For inference to succeed the starting and ending literal character spans (if any) of the target must exactly match the starting and ending spans of the source. Inference proceeds by matching each placeholder to a substring in the source from left to right: A placeholder followed by a literal character span is matched by inferring zero or more characters from the source until the first occurrence of that literal character span in the source. A placeholder immediately followed by another placeholder is matched by inferring a single character from the source.

    You have 2 placeholders here so the first one tries to consume a single character - but there is none when you instantiate this T with an empty string so the whole thing doesn't get matched. I think this makes sense.

    One extra quirk related to empty strings that I know of is this one: #49839 (comment) . It's quite similar to your problem here - the inferred Last doesn't match the constraint, so it's replaced by it. Then we are left with a string that contains 5 placeholders and the last placeholder is not followed by another placeholder... so it just consumes the rest of the string and the conditional type gets satisfied.

  8. ghostinpeace commented on Jan 24, 2024

    @ghostinpeace
    Author

    I was trying to investigate about the behavior we can observe in the example you linked.
    But there was another problem in the way...

    type Exactly3<T extends string> =
      T extends `${any}${any}${any}${infer Last extends ''}` ?
        T :
      never;
    
    type Exactly3_Infer<T extends string> =
      T extends `${infer A}${infer B}${infer C}${infer Last extends ''}` ?
        [A, B, C, Last] :
      never;
    
    type T = Exactly3<'abcd'>; // 'abcd' ie. the original problem
    type T_Infer = Exactly3_Infer<'abcd'>; // never ie. you shall never know what was going on

    Is it because having found fitting types for A, B and C on the "first run", there is no going back in the back-anchored attempt for Last? Assuming it even takes place since with that in mind, it could fail immediately...
    The only thing I can say for sure is that I'm still confused.

  9. Andarist commented on Jan 24, 2024

    @Andarist
    Contributor

    Not quite. I think it's just because this version "removes" the placeholders - and thus it removes the behavior associated with them.

    1. you have 4 inferred types: "a" (A), "b" (B), "c" (C), "d" (Last)
    2. the first three are just "accepted" because they have no constraints
    3. but the inferred type for Last can't be accepted - it doesn't match the constraint, so it's replaced with the constraint ("")
    4. now we instantiate this whole template literal type with those results and we essentially end up with "abc" (no placeholders!)
    5. and the input ("abcd") is checked against that - it doesn't extend "abc" so the falsy branch gets returned
  10. typescript-bot commented on Jan 27, 2024

    @typescript-bot
    Contributor

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

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

    DuplicateAn existing issue was already created

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions