Sound Typing for “this” in Flow

Daniel Sainati
Flow
Published in
8 min readJun 2, 2021

In our most recent post, we alluded to some upcoming changes that would restrict some common JavaScript features. In this post, we will outline the first few of these changes, which you can expect to see in upcoming releases, and which are designed to address one of Flow’s longest-standing unsoundnesses: typing the this parameter.

Photo credit to Pixabay from Pexels

The this parameter of functions and methods has historically been one of Flow's major blind spots; we were able to infer a type for this when functions were used within a file, but had to resort to inferring any for any exported functions. Class methods assumed that methods were called on an instance of the class, but this was not enforced. So the following code would not generate any errors in Flow.

class C {
foo() {}
bar() { this.foo() }
}
var bar = new C().bar;
bar(); // runtime error!

Similarly, in the below code, Flow would allow you to call the exported function from other files with any this argument, and you were unable to provide an annotation for the parameter.

export function foo() : number {
return this.bar;
}

To address these issues, Flow is adding support for annotating this parameters of functions and methods, as well as restricting and modifying the way that this interacts with some features of classes, class methods, and objects. To provide sound and simple behavior for this new feature, Flow will error when class methods are unbound and when this is used inside object methods. Flow is also restricting the ways that class instances interact with object types. The behavior of the this parameter is commonly a source of confusion, and JavaScript itself has evolved away from using it by introducing new language features like arrow functions and classes that discourage its use. By eliminating outdated, complex language features and instead focusing on modeling modern idioms, we can make Flow a simpler, safer and friendlier language.

However, this is a large set of changes that modify some pretty important parts of Flow’s type system, so updating your codebase to be compatible with these improvements may be challenging, and may take some time. We have released some tools (which will be detailed below) that will help with these improvements, but we encourage upgrading to the newest version of Flow and suppressing (and then fixing over time) the new errors, rather than attempting to fix all the errors ahead of time. That all said, we think the benefits are very worth it, so let’s dive into the specifics:

"this” parameter annotations

Inspired by TypeScript, we will be adding support for this annotations in a special position at the beginning of a function's parameter list. When present, the this parameter must always have an annotation, and must always appear first in the list. The following are valid examples of this annotations:

function foo(this: { x: string }) {
/* code */
}
interface I {
m(this: I): void;
}
class A {
m(this: I): void {
/* code */
}
}

while the following are not:

function bar(x: number, this: string) {
/* code */
}
function baz(this, ...args) {
/* code */
}
let x = (this: number) => {
/* code */
};

A couple additional points about this parameters to be aware of:

  • As you can see in the above example, arrow functions cannot have this parameters, as they bind this at the definition site, rather than the call site. This would make a this parameter useless on such a definition.
  • Functions and methods now check their this argument against their annotated (or inferred) this parameter at call time. So, using the example above, foo.bind({x: 3})() would error on an incompatibility between the value of 3 and its expected string type.
  • Classes will default to inferring the instance type for the this parameter of instance methods, and the class type for the this parameter of static methods, unless otherwise annotated. Declared classes and interfaces will default to mixed.
  • Class and interface methods will require that their this parameter is a supertype of the type of the interface or class, as this is required for subtyping to be sound. Annotating a class method with a specific this parameter will affect the type of this inside the method, potentially restricting the properties or methods that can be accessed in the body.
  • As a result of these changes, using Flow after this change will require updating to a version of Babel that has support for this new syntax: at least version 7.13.0, since in order to resolve incompatibilities between this parameters, you may need to explicitly annotate them. Additionally, in future versions of Flow (more on this in another upcoming post), we will be requiring explicit this parameter annotations on top-level functions using this in the body.

This has a number of benefits: it allows Flow to make stronger guarantees about the this parameters of methods, as well as type patterns we previously could not support. For example, in the past we loosely typed Array methods like map using mixed for the callback’s thisArg, which did not actually check against the callback properly. Now instead of writing the method definition for map like this:

map<U>(callbackfn: (value: T, index: number, array: Array<T>) => U, thisArg?: mixed): Array<U>

we can instead type map more safely as:

map<U, This>(callbackfn: (this : This, value: T, index: number, array: Array<T>) => U, thisArg: This): Array<U>

Restrictions on method unbinding

Consider the following code sample in which a class method is rebound, resulting in a runtime error:

class B {
m() {}
}
class C extends B {
bar = () => null;
m() {
this.bar();
}
}
var x: B = new C();
x.m.apply(new B()); // rebinding leads to a runtime error!

This will error at runtime, as B does not have a defined bar property, but x.m requires bar to exist on its this argument. One might wonder what this parameter we might infer here that would detect this error, but it turns out that there is in fact no practical sound way to prevent this type of runtime error from occurring without banning subclassing outright. In order to ensure that this parameter annotations are sound, starting with version 0.153.0 Flow will ban the unbinding of class methods from the context in which they were defined.

This means that all of the following will error:

class A {
m() {}
}
let a = new A();// error: explicitly unbinding m from a
let b = a.m;
// error: bind, apply and call cannot be used with class methods
a.m.bind({});
// error: destructuring implicitly unbinds m from a
let { m } = a;

Flow’s LSP interface supplies quickfixes for these new errors, so if you run into them while writing code, you will be able to apply quickfixes to resolve them automatically. Additionally, in order to facilitate upgrading your codebase past this change, we are introducing a new command to Flow’s CLI interface: fix. Running flow fix --write in your project will modify your code by applying the quickfixes for these new errors in all the places they show up. In particular, the fix command will modify unbound methods to become arrow method properties. In the above example, the class A would be rewritten as:

class A {
m = () => {}
}

It is worth noting that this may introduce variance errors (since methods are covariant) that may need to be fixed, and is not entirely safe if you were explicitly rebinding these class methods or accessing them directly from the class prototype. In particular, you may need to ensure that class hierarchies have the same type of definition for matching fields. I.e., if m is a method on A, it should be a method on all superclasses and subclasses of A. The same principle holds for function or arrow properties. In cases where methods cannot be easily transformed into arrows, we suggest suppressing the unbinding errors to ease the version upgrade.

Restrictions on this in objects

Flow will be banning the use of this inside of object methods. We recommend assigning the object to a variable and referencing that to achieve the same kind of behavior. For example, you could rewrite this code:

let o = {
x: "",
write(s: string) {
this.x = s;
},
read() {
return this.x;
},
};

into:

let o = {
x: "",
write(s: string) {
o.x = s;
},
read() {
return o.x;
},
};

It is difficult to automate this change, so we suggest that in order to upgrade past this change, you suppress the new object-this-reference errors that appear and fix instances of this as you encounter them in your code.

Changes to class-object subtyping

The final change in this set reorganizes the type hierarchy for objects and classes. Currently in Flow, an inexact object that defines the same fields and methods as a class is a valid supertype of an instance of that class. In version 0.153.0 of Flow, we are changing this behavior: objects will no longer be valid supertypes of class instances; instead, interfaces can be used to achieve the same effect. So code like the following:

function foo(x: { p: number, ... }) {
/* code */
}
class A {
p: number;
}
foo(new A()); // class-object-subtyping error

can be rewritten like this:

function foo(x: interface { p: number }) {
/* code */
}
class A {
p: number;
}
foo(new A());

This restriction was put in place to make the previously mentioned changes safe; since we are adding differing behavior to classes and objects, it no longer makes sense to be able to cast between them. Object types will now be distinct from classes, while interfaces will express the supertype of both structures. Conceptually, you can think of an interface as a type describing the accessible properties and methods on a value, whether it’s a number, a class or an object. Interfaces are similar to inexact objects in their subtyping behavior, so all the following are valid uses of interfaces:

let a: interface { length: number } = "";let b: interface { toUpperCase(): string } = "";let c: interface { toFixed(fractionDigits?: number): string } = 3;let d: interface { x: string } = { x: "", y: "" };let e: interface { x: string } = class {
static x = "";
static y = "";
};
let f: interface { x: string } = new (class {
x = "";
y = "";
})();

However, interfaces will also have the same restrictions as classes on unbinding their methods.

In order to facilitate upgrading your codebase past this change, the fix command mentioned above will include an autofix that automatically refactors object types to interfaces when they appear as supertypes of classes (the example above with the function foo demonstrates what this change will look like). As was the case previously, this autofix will also be applicable directly from the IDE when you encounter this error.

--

--

Flow
Flow

Published in Flow

The official publication for the Flow static type checker for JavaScript. Code faster. Code smarter. Code confidently.

Responses (1)