TypeScript
TypeScript copied to clipboard
Conditional Type Inference Bug in TS 5.x
🔎 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.
I could reduce the code even more. I believe it's minimal now. If I reduce the code more the problem goes away.
Bisects to #55941
cc @Andarist - any thoughts? Should we revert that PR?
@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
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
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 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?
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 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 @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
?
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.
This issue has been marked as "Working as Intended" and has seen no recent activity. It has been automatically closed for house-keeping purposes.
@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.