Local Type Inference for Flow

Sam Zhou
Flow
Published in
5 min readJan 17, 2023

Over the course of 2021 and 2022, we have built LTI (Local Type Inference), a rewrite of Flow’s type inference algorithm. This change makes Flow’s inference behavior more reliable and predictable. It replaces a global inference scheme that Flow has relied on since its inception and that has often been the culprit of confusing action-at-a-distance behavior. The complexity ingrained in the old system has consistently slowed down the Flow team in making impactful changes and delivering new features. Local Type Inference promises to address these issues, by modestly increasing Flow’s annotation requirement, bringing it closer to industry standard and capitalizing on increasingly strongly and explicitly typed codebases. More context motivating this change can be found in our initial announcement post.

This new behavior is currently behind the inference_mode=lti flag. It will be turned on by default in 0.199.0, and the old behavior won’t be supported starting from 0.200.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.

In this post, we explain what exactly the changes are and how they benefit our users.

Improve Error Locality

While the old inference algorithm can save you time from not explicitly writing down the types, it can also lead to surprising “action-at-a-distance” behaviors. For example, we might start with the following well-typed code:

const onChange = (myString) => {
myString.substring(0, 10);
};
<TextInput onChange={onChange} /> // onChange prop expects: (string) => void;

Maybe a few hundred lines later, you added another call:

const onChange = (myString) => {
myString.substring(0, 10);
// ^^^^^^^^^ error: `substring` is missing in `Number`
};
<TextInput onChange={onChange} /> // onChange prop expects: (string) => void;
// a few hundred lines of code later...
onChange(3);

This type error is incorrectly positioned inside the function definition, instead of on the invalid onChange call.

In addition to the surprising error behavior, the old inference algorithm can lead to silent loss of coverage. In the example below, val will be inferred as empty, which is considered uncovered and it allows you to use it in arbitrary (unsafe) ways.

const f: mixed = (val) => { val.foo.bar() };

In the new algorithm, both problems are fixed by explicitly requiring annotations in these cases.

We are already requiring annotations in places where we know we definitely will not have a local typing context, and we will now require annotations in places where we have a typing context, but the typing context is not useful enough. In practice, most code already has enough local context, and they will continue to work without extra annotations. For example:

const f1 = React.useCallback((foo) => {}, []); // foo must be annotated
const f2: mixed = (v) => {}; // v must be annotated

[1, 2, 3].map(n => n + 1); // n is inferred as number
Array.from(new Set<string>(), s => s.length); // s is inferred as string

Cyclic definitions must be fully annotated

Flow’s old type inference algorithm used type constraints to find the return type of a function. This gave us the ability to infer the return type even in cyclic or recursive definitions. For example, Flow was able to infer return types in the following examples in the old inference algorithm, but will now require return annotations in the new inference algorithm:

function factorial(n: number) {
if (n == 0) return 1;
return n * factorial(n - 1);
}
function isEven(n: number) {
if (n == 0) return true;
return !isOdd(n - 1);
}
function isOdd(n: number) {
if (n == 1) return true;
return !isEven(n - 1);
}

Generic function calls are constrained at the call sites

Flow’s old type inference algorithm also allowed implicit generic type arguments to be inferred from its uses throughout the entire program. For example:

function MyComponent() {
const [str, setStr] = useState("");
const myRef = useRef();
const mySet = new Set();

// This call changes the inferred type argument of useState call
// from `string` to `?string`.
setStr(maybeString);
// This call changes the inferred type argument of useRef call
// from `void` to `number | void`.
myRef.current = 1;
// This call changes the inferred type argument of new Set call
// from underconstrained to number.
mySet.add(1);
}

While the old behavior might be convenient in some cases, it can lead to the same surprising action-at-a-distance behavior. For example, if we later add a call setStr(3) long after the definition of str, suddenly all of the places that uses str as ?string will now error:

function MyComponent() {
const [str, setStr] = useState("");
setString(maybeString);
str.substring(0, 1); // Error: `substring` is missing in `Number`
str.length; // Error: `length` is missing in `Number`
str.includes("a"); // Error: `includes` is missing in `Number`
expectString(str ?? ''); // Error: `number` is compatible with `string`.

// a few hundred lines of irrelevant code...
setStr(4); // no errors here :(
}

In the new inference algorithm, Flow will only infer type arguments at the call site. If the call site cannot provide enough type information, we will emit an error. The above example will now have the following new behavior:

function MyComponent() {
const [str, setStr] = useState("");
// Type argument is inferred to be void.
const myRef = useRef();
// Error: Type argument is under-constrained.
const mySet = new Set();

// Error: `?string` is incompatible with `string`.
setStr(maybeString);
// Error: `number` is incompatible with `void`.
myRef.current = 1;
// Under-constrained type argument defaults to any,
// so this line doesn't error.
mySet.add(1);

setStr(4); // Error: number is incompatible with string
}

To fix the under-constrained error or to keep the widened type, you will need to provide explicit type arguments. For example:

function MyComponent() {
const [str, setStr] = useStat<?string>("");
const myRef = useRef<?number>();
const mySet = new Set<number>();

setStr(maybeString);
myRef.current = 1;
mySet.add(1);
}

Future projects enabled

With all the changes described above, we are finally in a position to clean up some of our technical debt and start delivering new features again. In the next year, we plan to build on top of the local type inference project, and start delivering long awaited new features and fixing soundness bugs, including but not limited to:

Upgrading your own repos

We have shipped various codemods to help you upgrade your repos:

# Annotate all parameters that are only required to be annotated in LTI.
flow codemod annotate-functions-and-classes --include-lti --write .
# Annotate declarations to help unbreak type cycles.
flow codemod annotate-cycles --write .
# Annotate underconstrained react hooks like useState(null).
flow codemod annotate-react-hooks --write .
# Annotate underconstrained empty arrays.
flow codemod annotate-empty-array --write .
# Annotate under-constrained type arguments.
flow codemod annotate-implicit-instantiations --write .
# Annotate type arguments that might be widened.
# This is a noisy codemod that doesn't always produce what you might want.
flow codemod annotate-implicit-instantiations --write --include-widened .
# Same as above, but it will annotate function returns like
# `array.map((v): T => {...})` instead of `array.map<T, _>(v => {...})`
flow codemod annotate-implicit-instantiations --write --include-widened --annotate-special-function-return .

These codemods will only insert type annotations, so there should be no runtime changes. Still, you should check the final result before deploying your code to production. You must be using Flow version 0.197–0.199 to run the codemods, as they may be deleted when the flag was deleted (0.200)

--

--