Typescript: Destructuring assignment gives different type than field access for some function calls

Created on 30 Apr 2020  Β·  6Comments  Β·  Source: microsoft/TypeScript


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

Working as Intended

Most helpful comment

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.

All 6 comments

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 }

playground for the above here

Weirdly, a does get the string type when default values are added:

const { a = 1 } = foo() // a is string | 1, T is BaseType

playground for default value

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, 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?

The process goes as follows:

  • First we infer a type argument for the 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.
  • Next we instantiate the function return type, producing { a: any } & { a: string }, which reduces to { a: any }.
  • Finally, we infer from the instantiated function type to the binding pattern { 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.

Was this page helpful?
0 / 5 - 0 ratings