TypeScript icon indicating copy to clipboard operation
TypeScript copied to clipboard

Conditional Type Inference Bug in TS 5.x

Open fxdave opened this issue 1 year ago • 1 comments

🔎 Search Terms

"Inference trims types", "inference excludes fields"

🕗 Version & Regression Information

  • This is not a crash
  • This changed between versions _4.9 and ___5.0
  • This changed in commit or PR _______
  • This is the behavior in every version I tried, and I reviewed the FAQ for entries about _________
  • I was unable to test this on prior versions because _______

⏯ Playground Link

https://www.typescriptlang.org/play/?#code/C4TwDgpgBAYg9nAPAFQHxQLxQN5QHYCGAthAFxQBEAZghVAL5QBkUyA3AFCiRQBCBAJxTosuAG4EANgFcyrBpy7hoyAJYATVRHXDM8iAA9gEPOoDOfQYlV4qEAVABq6APysNW9QH14SNFHJ2JR41TW0fBF0sZChDY1MLX2tbeydXeUDFKmk8AGNgVTg8KBo-VAAKXIAjQI9tYQBKHA4oKAEIYGkBYurOeg4OXKKzYDaIM2lJYABGPVLy3EIScmpaABooEjMzAgBzOQoACQgQAEI6RgJEyNwtnf3yEYEbXYZUBs52ianpgDoliBsKAAemBUAAogIBHABOQAArQyACUBQADkANRUHUcHG+Dgo0MqhGUCKUG40FRt3G9zkTxeQPoqIGQzwxK+k2AACY5ggFvhiAdShQNnc9gdjmcLh8OOyppz-gKgaCoAA5OCxKEwoA

💻 Code

type Foo<T> = { name: "foo" } & T;
type Bar<T> = { value: T };

type Tidied<T> = T extends Bar<infer V> ? Tidied_Foo<T> : T;
type Tidied_Foo<T> = T extends Foo<infer V> ? T : T;

function foo<T>(cb: Tidied<T>) {
  return cb;
}

const result1 = foo({ name: "foo", message: "Hey!" } as Foo<{ message: string }>);
result1.name; // Error: Property 'name' does not exist on type '{ message: string; }'

const result2 = foo({ name: "foo", message: "Hey!" });
result2.name; // No error

🙁 Actual behavior

packages/server/src/test.ts:13:9 - error TS2339: Property 'name' does not exist on type '{ message: string; }'.

13 result1.name; // ...
           ~~~~


Found 1 error in packages/server/src/test.ts:13

🙂 Expected behavior

no type error

Additional information about the issue

I was working on my project Cuple. I found a weird bug. I spent my day to provide a small minimal example. I have a type Tidied which is used to make a developer friendly type from the complex built response types. It worked well for some cases but it doesn't work for the case that I provided. The original version makes more sense.

fxdave avatar May 13 '24 20:05 fxdave

I could reduce the code even more. I believe it's minimal now. If I reduce the code more the problem goes away.

fxdave avatar May 20 '24 19:05 fxdave

Bisects to #55941

RyanCavanaugh avatar Jun 06 '24 20:06 RyanCavanaugh

cc @Andarist - any thoughts? Should we revert that PR?

RyanCavanaugh avatar Jun 06 '24 20:06 RyanCavanaugh

@RyanCavanaugh hm, I'm not sure if this bisect result is correct. The referenced PR has been included since 5.4.0-dev.20231201 but the reported change is between 4.9 and 5.0

Andarist avatar Jun 06 '24 20:06 Andarist

This changed between 5.0.0-dev.20221103 and 5.0.0-dev.20221108 . Looking at the commits within that range, I think the change was caused by https://github.com/microsoft/TypeScript/pull/51405

Andarist avatar Jun 06 '24 20:06 Andarist

I can confirm that https://github.com/microsoft/TypeScript/pull/51405 changed this and it looks like working by design to me.

Matching types are eliminated from inference here as per the comment close to that touched code:

// We reduce intersection types unless they're simple combinations of object types. For example,
// when inferring from 'string[] & { extra: any }' to 'string[] & T' we want to remove string[] and

This code tries to infer from Foo<{ message: string; }> to { name: "foo"; } & V & T. That source is an aliased { name: "foo"; } & { message: string; } so { name: "foo"; } gets matched in both source and the target and "eliminated". Then it proceed to infer between the leftovers:

source // { message: string; }
target // V & T

This call is meant to return T, so ye - T doesn't have .name here as that part of the original intersection was matched away.

Andarist avatar Jun 06 '24 21:06 Andarist

@Andarist Thanks for the investigation. I would use the unsimplified version, but if Typescript is going that direction, I can live with it. Do you think we can show a warning so this case wouldn't look like unexpected?

fxdave avatar Jun 06 '24 21:06 fxdave

Do you think we can show a warning so this case wouldn't look like unexpected?

Where you'd like to show a warning? Inference is incapable of raising warnings

Andarist avatar Jun 06 '24 22:06 Andarist

@Andarist is right - this code isn't really distinguishable from code that is removing name on purpose

We can't warn every time any possible thing happens.

RyanCavanaugh avatar Jun 06 '24 22:06 RyanCavanaugh

@RyanCavanaugh @Andarist I've just checked the comments again and something is not clear to me.

As I see, my Tidied should be equivalent with this: type Tidied<T> = T because every case resolves to T.

Why would this mean that I want to remove a random property from the type?

I also tried this for curiosity (this is the same problem without functions):

type Infer<T> = T extends Tidied<infer V> ? Tidied<V> : null
type Baz = Infer<Foo<{ message: string }>>

This removes "name" from the type, however, not with type Tidied<T> = T.

So what's the difference between this:

type Tidied<T> = T extends Bar<infer V> ? Tidied_Foo<T> : T;
type Tidied_Foo<T> = T extends Foo<infer V> ? T : T;

and this:

type Tidied<T> = T

?

fxdave avatar Jun 09 '24 00:06 fxdave

T is unknown at the beginning and TS tries to infer what it could be to satisfy your constraints. It's all heuristics here. Both { message: string } and Foo<{ message: string }> are valid answers here. So this can't be qualified as a bug - the current behavior just doesn't match your original expectations.

Andarist avatar Jun 09 '24 17:06 Andarist

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

typescript-bot avatar Jun 12 '24 01:06 typescript-bot

@Andarist Thank you. I think I understand it now.

For example, because type Foo<A> = A is only A, A can be anything that satisfies B in this scenario:

type Infer<B> = B extends Foo<infer A> ? Foo<A> : null

So here, typescript resolves to a seemingly random but correct type through its heuristics.

Infer<{ bar: string }> = Foo<unknown> // ✅ correct
Infer<{ bar: string }> = Foo<{}> // ✅ correct
Infer<{ bar: string }> = Foo<{ bar: string }> // ✅ correct

Similarly if the type is little bit more complex, e.g.: type Foo<A> = { bar: A } then again,A can be anything. We can be sure that the property bar is there but we cannot be sure about its type. For example:

Infer<{ bar: { baz: string } }> = Foo<unknown>  // ✅ correct
Infer<{ bar: { baz: string } }> = Foo<{}> //  ✅ correct
Infer<{ bar: { baz: string } }> = Foo<{ baz: string }> //  ✅ correct
Infer<{ bar: { baz: string } }> = Foo<{ baz: string, answer: 42 }> //  ✅ correct, but unlikely

Am I right? And, in that case, when can I rely on it?

A more practical use case would be:

type ArrayItem<T> = T extends Array<infer V> ? V : never

I would expect that I get TValue from Array<TValue> always, but with the current version of Typescript I only get a subtype of TValue.

fxdave avatar Jun 12 '24 10:06 fxdave