TypeScript Version: 3.7.4 -> 3.9 RC
Search Terms: destructure, destructuring-assignment, object destructuring affects function type
Code
declare function foo<T = {}>(): {a: string} & T
const result = foo() // result.a is type string
const { a } = foo() // a is type any
Expected behavior:
result.a and a should have the same type, string
Actual behavior:
result.a is type string, and a is type any. Additionally, the destructuring seems to have changed foo's type.
given how simple this repro is, I feel like i'm missing something :/
Playground Link:
https://www.typescriptlang.org/play/?ts=4.0.0-dev.20200429&ssl=4&ssc=20&pln=1&pc=1#code/CYUwxgNghgTiAEAzArgOzAFwJYHtVJxwB4AVeAXngG8BfAPgAoBKALmqjYGcMYtUBzGvABk8EgChxYPN3hxOyCBgoEczKTOVV4UeEMqJCzIA
Related Issues:
https://github.com/microsoft/TypeScript/issues/3708 potentially
This is working as intended. Take the following example:
declare function foo<T>(): T;
const x = foo(); // T = unknown
const { a } = foo(); // T = { a: any }
First, in general there's something suspicious about a function that returns a generic type but doesn't use that generic type it's parameter list (after all, how's it possible for the function to know what to return?). But, that aside, given no occurrences of T in the parameter list, we make the best possible inference we can from the left hand side of the assignment. Now, even when T is intersected with another type (as in T & { a: string }) we still make the same inferences for T. So, we end up with { a: any } as our inference.
One could argue that we should infer { a: unknown } instead of { a: any }. Or, even more radically, that we should infer unknown in both cases because a discrete variable vs. a destructuring shouldn't affect the outcome. But that's a different matter, and certainly would be a breaking change.
@ahejlsberg the use of the binding pattern as an inference site seems increasingly sketchy to me the more of these reports I see. What scenarios motivated us to add that?
@ahejlsberg thank you for your detailed response to this report, in particular, your insight into how typescript tries to make the best inference it can from left hand side of the assignment when T is not provided.
I do still have a question about that logic in regards to when T is intersected with another type (like T & { a: string } ). It does seem reasonable to me that T & { a: string } could aid typescript's inference for the left side, that a is a string, and everything else would be any/unknown? is it correct to say that typescript willingly ignores type information in this case?
Let me know if I should open a separate ticket, but I was also able to replicate this using extends:
type BaseType = { a: string }
declare function foo<T extends BaseType = BaseType>(): T
const result = foo() // T is BaseType
const { a } = foo() // T is { a: any }
Weirdly, a does get the string type when default values are added:
const { a = 1 } = foo() // a is string | 1, T is BaseType
Isnt that inconsistent with the flow of original example, where lacking a T parameter, a would be inferred as 1 | any -> any?
Anyway these examples are getting pretty weird, but thought I would provide some more findings in case it helps.
@RyanCavanaugh Thank you as well for taking time on this report. You all are awesome.
It does seem reasonable to me that
T & { a: string }could aid typescript's inference for the left side, thatais a string, and everything else would be any/unknown? is it correct to say that typescript willingly ignores type information in this case?
The process goes as follows:
foo() call. We make no inferences from the arguments (because there aren't any), so we infer from the _contextual type_ of the call to the return type of the function. The contextual type of the call is the type _implied_ by the destructuring, { a: any }, so that's what we infer for T.{ a: any } & { a: string }, which reduces to { a: any }.{ a }, which yields type any for a.So, it's not that we ignore type information, it's just that the implied type { a: any } ends up neutralizing { a: string }.
The new examples you give above are also working as intended. Part of what you're seeing there is that when an inferred type argument fails to satisfy the constraint for the type parameter, we revert to the constraint (i.e. BaseType in your examples).
the use of the binding pattern as an inference site seems increasingly sketchy to me the more of these reports I see. What scenarios motivated us to add that?
The motivating scenarios are mostly higher order functions where a type parameter in the return type ends up in an input position (because the return type is a function). For example:
type BoxFunc<T> = (x: T) => [T];
function makeBoxFunc<T>(): BoxFunc<T> {
return x => [x];
}
let fs: BoxFunc<string> = makeBoxFunc();
let fn: BoxFunc<number> = makeBoxFunc();
let s = fs('hello'); // [string]
let n = fn(42); // [number]
There's nothing unsound or suspicious here because the x parameter (which is contextually typed as T) in the arrow function is an input.
This issue has been marked 'Working as Intended' and has seen no recent activity. It has been automatically closed for house-keeping purposes.
Most helpful comment
This is working as intended. Take the following example:
First, in general there's something suspicious about a function that returns a generic type but doesn't use that generic type it's parameter list (after all, how's it possible for the function to know what to return?). But, that aside, given no occurrences of
Tin the parameter list, we make the best possible inference we can from the left hand side of the assignment. Now, even whenTis intersected with another type (as inT & { a: string }) we still make the same inferences forT. So, we end up with{ a: any }as our inference.One could argue that we should infer
{ a: unknown }instead of{ a: any }. Or, even more radically, that we should inferunknownin both cases because a discrete variable vs. a destructuring shouldn't affect the outcome. But that's a different matter, and certainly would be a breaking change.