microsoft / TypeScript

TypeScript is a superset of JavaScript that compiles to clean JavaScript output.
https://www.typescriptlang.org
Apache License 2.0
100.65k stars 12.44k forks source link

Inferring "this" from arrow function is {} #31719

Open jamiebuilds opened 5 years ago

jamiebuilds commented 5 years ago

TypeScript Version: Version 3.6.0-dev.20190601 Search Terms: This, Arrow, Functions, Infer, Inference, Empty, Object, Incorrect, Any

Code

type FunctionThisType<T extends (...args: any[]) => any> =
  T extends (this: infer R, ...args: any[]) => any ? R : any

let fn = () => {}
let value: FunctionThisType<typeof fn> = "wrong"

Expected behavior:

error TS2322: Type '"wrong"' is not assignable to type 'typeof globalThis'.

Found 1 error.

Actual behavior:

No errors found.

Playground Link: [Playground](https://www.typescriptlang.org/play/index.html#src=type%20FunctionThisType%3CT%20extends%20(...args%3A%20any%5B%5D)%20%3D%3E%20any%3E%20%3D%0D%0A%20%20T%20extends%20(this%3A%20infer%20R%2C%20...args%3A%20any%5B%5D)%20%3D%3E%20any%20%3F%20R%20%3A%20any%0D%0A%0D%0Alet%20fn%20%3D%20()%20%3D%3E%20%7B%7D%0D%0Alet%20value%3A%20FunctionThisType%3Ctypeof%20fn%3E%20%3D%20%22wrong%22%0D%0A)

Related Issues: None found.

kitsonk commented 5 years ago

Arrow functions don't have a bound this. They do not have any bindings and therefore don't have a this type. They always utilise the this of the context they are executed in.

jamiebuilds commented 5 years ago

That's incorrect, they always utilize the this of context they are defined in, which is why TypeScript is already able to lookup the this of arrow functions:

https://www.typescriptlang.org/play/index.html#src=let%20returnsThis%20%3D%20()%20%3D%3E%20this%0D%0Alet%20value%3A%20%22wrong%22%20%3D%20returnsThis()

kitsonk commented 5 years ago

Code flow analysis allows TypeScript to figure out what this is for an arrow function by looking at the context it is defined in, not because it can actually determine what it is bound to. It is not bound, therefore it doesn't have a this type. It doesn't "inherit" this, it uses the this of the lexical context. It doesn't have its own this. Per MDN (and many other sources):

An arrow function expression is a syntactically compact alternative to a regular function expression, although without its own bindings to the this, arguments, super, or new.target keywords.

fatcerberus commented 5 years ago

As an aside: I personally find the way arrow functions are talked about as "not binding their own this" to be confusing, FWIW. Before we had fat arrows, in ES5 we used to go (function() { ... }).bind(this) to get (roughly) the same result. That's called a bound function, per usual ES terminology. So when I hear "not bound" I automatically think of a regular old function expression that gets its this as a secret hidden parameter.

As a result of the above I've found it's easier to explain arrow functions to people as being "auto-bound", rather than "not bound". The way the spec (and the documentation surrounding it) is written is very confusing sometimes!

jamiebuilds commented 5 years ago

@kitsonk Yes I understand how arrow functions work and I understand how TypeScript inference works. I understand that a this type in an arrow function doesn't make much sense. However, the bug that I am reporting is that TypeScript does produce a type for this in an arrow function, and that type is incorrectly.

TypeScript could (and in fact does appear to in some cases) store the this context for an arrow function from its definition, and then it can later use that this type to fix this inference bug that I am reporting.

If TypeScript wants to change that type to any or something, that would also be fine, but right now I am able to produce correct JavaScript code that TypeScript reports as incorrect and this is the underlying cause.

And I don't need you to explain JavaScript to me, I worked on Babel, TC39, and Flow, I gots it

fatcerberus commented 5 years ago

My immediate instinct is that the safe thing to do with arrows would be to treat their this binding slot as being of type never. If you know you have an arrow function then it would never make sense to pass in anything meaningful in that position.

However, functions with fewer parameters are purposely assignable to types with more (on the basis that the extra values will simply be ignored), so this: unknown would probably be more idiomatic.

jamiebuilds commented 5 years ago

I was thinking about that. But I think that unknown would also be incorrect. Think about it this way:

interface MyThis { property: boolean }

function context(this: MyThis) {
  let a = () => { console.log(this.property) }
  let b = function(this: MyThis) { console.log(this.property) }.bind(this)
}

Should the inferred types of a and b be interchangeable (at least in the context of this)?

I would say yes, they should be.

fatcerberus commented 5 years ago

I would argue that any function returned by .bind() should also be typed as this: unknown, for exactly the same reason. So yes, those two cases should in fact be interchangeable, I'll agree with that. :smiley:

this is simply a special parameter which is (typically) fed by the semantics of the language rather than explicitly by the caller (you know this so I won't go into boring detail and patronize you :). So whether we use an arrow function or .bind() it away, I’d say it should be treated the same as any other nonexistent parameter for the purpose of assignability.

To expand the original example:

type FunctionThisType<T extends (...args: any[]) => any> =
  T extends (this: infer R, ...args: any[]) => any ? R : any

type FunctionArgType<T extends (...args: any[]) => any> =
  T extends (...args: [ infer R, ...any[] ]) => any ? R : any

let foo = () => {};

let bar = (x: number) => {};
let quux = () => {};

type FooThis = FunctionThisType<typeof foo>;  // {}

type BarArg = FunctionArgType<typeof bar>;    // number
type QuuxArg = FunctionArgType<typeof quux>;  // {}

For quux, we can we pass literally anything in as an argument and it will simply be ignored since the function doesn't have a binding slot for it. Therefore, we can assign bar = quux, even with --strictFunctionTypes enabled, and the compiler won't complain. this of arrow functions (and .bind()ed functions) should be treated the same, IMO.

jamiebuilds commented 5 years ago

For quux, we can we pass literally anything in as an argument and it will simply be ignored since the function doesn't have a binding slot for it.

This doesn't really matter, but that's now how .bind() works:

let a = (...args) => { console.log(...args) })
let b = a.bind(null, 1, 2, 3)
b(4, 5, 6)
// > 1 2 3 4 5 6

All functions in JavaScript, including arrow functions, have a this parameter that gets captured from different places. Saying

function outer() { return () => console.log(this) }
let inner = outer.call("foo")
inner() // "foo"

The inner arrow function captures a this context that it holds onto for its lifetime. To describe the this as unknown is inaccurate. It is known, it is a very specific thing, and it matters that TypeScript know that inside of the arrow function and outside of it.

function outer1(report) { return () => report(this) }
function outer2(report) { return function(this: unknown) { report(this) }.bind(this) }

To say that these are the same is inaccurate. And I don't think anyone would suggest that they should be the same. So what's different when we are inferring the type of an arrow function, why would we report it differently just because we're outside the arrow function? What values does that provide? The this isn't private to that function, it's not an implementation detail.

fatcerberus commented 5 years ago

The this inside an arrow function is just another variable that the function closes over. The this type of functions is its this parameter type, i.e. the type you would have to supply as the first argument to .call() or .apply(). Arrow functions don't have that parameter, and neither do .bind()-processed functions (because it's partially-applied away). So TypeScript treats it the same as other nonexistent parameters, i.e. {}.

fatcerberus commented 5 years ago

To iterate on the .bind() example: Would you not agree that:

let a = (aa: number, bb: string) => {};  // => void
let b = a.bind(null, 1);

...should return a function of type (bb: string) => void, because the aa: number was partially applied away? At one time we knew aa was a number, but we can no longer glean that information given we only know the type of b. It's no different with the this type.

jamiebuilds commented 5 years ago

this isn't quite a parameter, it's very close to one, but it does have distinctions. We would not want to conflate this as parameters = [this, ...arguments].

You can always infer types for this, args, and the return type. And while ((a, b) => {}).bind(_, a) transforms the inferred args from [a, b] to [b], you don't transform this into non-existence, you still have to be able to infer a type, and representing that type as unknown to signify "non-existence" is changing the meaning of unknown.

this: t => .bind(t) => ?
parameters: [a, b] => .bind(_, a) => [b]
return: x => n/a => x

It's a weird edge case, but ? has to be something. Whether that's:

{} is obviously incorrect, it implies that T is {}, but while it could be, that's not why TypeScript is giving it to you.

I would argue that unknown falls into the same bucket. It implies that T is unknown when it is not.

fatcerberus commented 5 years ago

Edge case or not, all I'm saying is that in the following declaration:

function foo(this: MyClass, x: string) {}

MyClass is the type you're expected to pass in for this. It says nothing (at least not directly) about the type of this inside the function. This is the definition the language gives it. If the type you're supposed to use for some parameter doesn't matter because there's no binding for it:

type FirstArgType<T extends (...args: any[]) => any> =
  T extends (...args: [ infer R, ...any[] ]) => any ? R : any
type FA = FirstArgType<() => void>;  // {}

TypeScript infers that type to be {} (which is very close to unknown). This is why you can assign (a: string) => void to (a: string, b: string) => void etc., by way of contravariance. (unknown being the contravariant complement of never).

fatcerberus commented 5 years ago

Just to give one final counterexample, you would expect the below to work, correct?

class Foo
{
    name = "Foo";

    blub(callback: (this: Foo, message: string) => void) {
        callback.call(this, "glub glub!");
    }
}

class Bar
{
    name = "Bar";

    flub() {
        const foo = new Foo();

        // This is fine, for obvious reasons:
        foo.blub(msg => console.log(`${this.name} says: ${msg}`));

        // But this is an error, also for obvious reasons:
        foo.blub(function(this: Bar, msg) { console.log(`${this.name} says: ${msg}`); });
    }
}

const bar = new Bar();
bar.flub();

Assuming that --strictFunctionTypes is enabled, because of contravariance, this only works if the this: type of the arrow function is treated as the top type (unknown) or something very close to it.

I hope the example above was self-explanatory, but if not, to get back to original example, when you ask the compiler to infer:

FunctionThisType<typeof arrowFn>

The question is “what type do I have to give as the first argument to arrowFn.call()? And the compiler is essentially telling you:

{}. It’s an arrow function; you can pass anything you want for this. It already has its own.

This is IMO half the reason to use an arrow function at all: a this will always be passed in implicitly by the semantics of the language, so we set it to a universal input and then ignore it in favor of using our own.

RyanCavanaugh commented 5 years ago

I will say some things that are obvious for the sake of exposition so please don't think I don't think either of you understand how JS works.

There are two interpretation of this in play:

The first matter is entirely taken care of because it's only visible inside the function body.

The second matter is what's at stake here and is the only meaningful interpretation of what FunctionThisType means - given a function that you're not already inside, what is a legal way to call it?

For an arrow function, it doesn't matter what the apparent binding of a call to the function is. When the type of a value doesn't matter, it has historically been {}, but since the introduction of unknown, we have generally preferred that type and have taken a few breaking changes (of which this would be one) to move to the more accurate unknown type.

So I'd be inclined to change this to a slightly more precise unknown value instead of {}, because given an arrow function f: () => void and a value u: unknown, f.call(u) is OK.

It implies that T is unknown when it is not.

Nothing isn't unknown. That is the definition of unknown - it is the type which all values inhibit.

RyanCavanaugh commented 5 years ago

Concrete example of desirable behavior:

function callWithThis<T extends Function>(fn: T, ths: ThisType<T>) {
    fn.call(ths);
}

const arrow = () => 0;
// Should be OK
callWithThis(arrow, null);