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

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

Closed
ghostinpeace opened this issue Jan 22, 2024 · 10 comments
Closed
Labels
Duplicate An existing issue was already created

Comments

@ghostinpeace
Copy link

πŸ”Ž 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

@Andarist
Copy link
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;

@RyanCavanaugh
Copy link
Member

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

@RyanCavanaugh RyanCavanaugh added the Duplicate An existing issue was already created label Jan 22, 2024
@Andarist
Copy link
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).

@ghostinpeace
Copy link
Author

ghostinpeace commented Jan 23, 2024

Thank you for explaining.

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

@Andarist
Copy link
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, "">;

@ghostinpeace
Copy link
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.

@Andarist
Copy link
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.

@ghostinpeace
Copy link
Author

ghostinpeace commented Jan 24, 2024

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.

@Andarist
Copy link
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

@typescript-bot
Copy link
Collaborator

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Duplicate An existing issue was already created
Projects
None yet
Development

No branches or pull requests

4 participants