Photo by Keegan Everitt on Pexel

New Flow Language Rule: Constrained Writes

Jordan Brown
Flow
Published in
4 min readAug 5, 2022

--

Flow is releasing a new language rule that determines the type of an unannotated variable at its initialization. Along with these new rules come several fixes to soundness bugs that were causing refinements to not be invalidated. This is a major semantic change that we are rolling out as part of our gradual migration to local type inference.

This new behavior is currently behind inference_mode=constrain_writes. It will become the default mode in 0.185.0, and the classic mode will be removed in 0.186.0.

Enabling the flag will likely cause errors in your project. We are providing codemodding tools to help ease the transition, and you can find detailed instructions for upgrading at the end of this article.

This post is the first of two in a series that describe the new typing environment in Flow. The rest of this post explains the changes to how Flow analyzes your code. If you would like to learn even more about the changes here and how they further enable our transition to local type inference, look out for a more technical deep dive on our new environment design coming in the next few weeks.

Stricter typing for unannotated variables

The type of a variable will now be determined by the type of its initializer, instead of Flow’s current behavior that allows the type of an unannotated variable to “widen” throughout the entire program. Let’s take an example:

// @flow
let x = 3;
//... much later
x = 'str';

Prior to these changes, Flow would allow the assignment of 'str' to x. Now, Flow infers that the type of x is number because it was initialized to 3, so instead it will error at the second assignment. This brings the behavior of unannotated variables much closer to the behavior of annotated variables, resulting in much more predictable inference behavior.

We made this change for two reasons: Flow’s old behavior was confusing, and it was also fundamentally incompatible with our push to local type inference.

For a more detailed explanation of the new rules please see the documentation, which covers cases including variables declared without initializers and variables initialized to null.

More accurate refinement invalidation

As part of this change, we found instances in which Flow unsoundly preserved a refinement that should have been invalidated. There were two patterns that exhibited this behavior most frequently:

function foo() {
let a: number| null;
bar(); // 2) Function call *before* the refinement
if (a == null) throw ''; // 1) This is the refinement
function bar() {
a.toFixed(); // 3) Runtime crash!
}
}

The example above passes in Flow despite it crashing at runtime. a is refined after bar is called, so at runtime the call to toFixed() will crash! These changes correctly determine that a in bar may be null, so it errors at the call of toFixed().

let a: number | null = 3;function assignA(): boolean {
a = null;
return true;
}
if (a != null && assignA()) {
a.toFixed(); // Runtime crash!
}

Flow also passes in this example despite the runtime crash. This is because Flow was not invalidating refinements on function calls that occurred in the guard of an if (while, for, etc.) statement. With these changes, we now error at the call of toFixed() saying that a may be null.

But we aren’t just more strict in all cases. We also found unnecessary errors that were removed because Flow was being too strict with its refinement invalidation:

type Obj = {foo: ?(mixed) => void};
let a: Obj = {};
function invalidate(a: Obj) {a.foo = null}
if (a.foo != null) {
a.foo(invalidate(a)); // No more error!
}

Flow would error at the call of a.foo in this example, but it does not need to. a.foo is evaluated before the call to invalidate! We no longer error with the new behavior on this example.

The rest of the new errors and how to fix them

As part of these changes, we had to make our inference around loops slightly less precise. In practice, it’s very unlikely that you’ll run into these kinds of errors, but they are worth documenting:

let x: null | number = 42;if (x!= null) {
while (x > 3) { // Error, the refinement on x gets invalidated
x--;
}
}

We much more aggressively invalidate refinements when entering loops if the variable is modified by the loop. We had to make this compromise in order to enable other parts of LTI. While regrettable, we estimate that this happens extremely rarely in practice.

Upgrading your own repos

We are also shipping two codemods in the Flow binary to help you upgrade your repos. You’ll want to run:

$ flow codemod rename-redefinitions --write .
$ flow codemod annotate-declarations --write .

rename-redefinitions will split a variable that is used with multiple types into separate variables each with a single type if their lifetimes do not overlap. For example:

let x = 3;
(x: number);
x = 'str'; // Error in the new mode
(x: string);
----Transforms Into----let x = 3;
(x: number);
const xStr = 'str';
(xStr: string);

This codemod should have no semantic changes, but since it is touching runtime-level code you should be sure to audit the changes it generates.

annotate-declarations will add union type annotations to values that still rely on the old “widening” behavior that cannot be split via rename-redefinitions. For example:

let x = 3;
if (Math.random()) {
x = 'str'; // Error in the new mode
}
----Transforms Into----let x: number | string = 3;
if (Math.random()) {
x = 'str';
}

This second codemod only inserts type annotations, so there should be no runtime changes. Still, it’s worth auditing the results of the codemod before pushing the changes to production.

--

--