Inference engines: 5 examples with TypeScript, Flow and Reason
In this post, we will go through some examples to try to understand better how Flow, TypeScript and Reason (OCaml) type inference engines differ. I did this because I’m interested in learning more about things like:
- inference contexts and scope: does it help us write fewer type annotations? (I greatly dislike writing type annotations)
- inference engine accuracy
- error messages: are they clear enough?
- errors location: does the error happen in meaningful places?

Please 🙏 pick this exercise as what it is: a very small sample of the many many aspects from which these tools can (and should) be evaluated. Things like type system expressiveness, performance, documentation, etc could also be part of it. But in order to keep the post relatively small, let’s get started with these ones first:
Case 1: inference in local scope
Extracted from this comment about soundness and inference on the TypeScript repo.
Side note: To be fair to TypeScript and Flow, in this case, it’s unclear whether this is the programmer intentionally writing in an imperative style, or made a typo. This is a limitation of JavaScript. In Reason, such patterns are expressed using other constructs that don’t cause confusion to the reader or type system.
🍊 Flow
var x = { kind: 'a' };
if (Math.random() > 0.5) {
x = { xyz: 'b' };
}
Flow doesn’t complain either when doing something like:
var x = 2;
if (Math.random() > 0.5) {
x = "bla";
}
With the second assignment, a union type number|string
is created, and every new assignment after it just adds another basic type to said union.
🍏 TypeScript
var x = { kind: 'a' };
if (Math.random() > 0.5) {
x = { xyz: 'b' }; // <--- Error here
}
Type '{ xyz: string; }' is not assignable to type '{ kind: string; }'.
Object literal may only specify known properties, and 'xyz' does not exist in type '{ kind: string; }'.
🍎 Reason
[@bs.val] [@bs.scope "Math"] external random : unit => float = "random";let x =
if (random() > 0.5) {
{"kind": "a"}
} else {
{"xyz": "b"} // <--- Error here
};
This has type:
Js.t({. xyz : string })
But somewhere wanted:
Js.t({. kind : string })
The second object type has no method xyz
Case 2: Inference across functions
🍊 Flow
function doSomething(x) {
console.log(x.foo); // <--- Error here
}
var s = {bar: 100};
doSomething(s);
2: console.log(x.foo);
^ property `foo`. Property not found in
2: console.log(x.foo);
^ object literal
🍏 TypeScript
function doSomething(x) {
console.log(x.foo);
}
var s = {bar: 100};
doSomething(s);
No error. The type system infers that x
is of type any
so x.foo
is correct. This can be fixed with the --noImplicitAny
flag (see “Case 3: Strict Mode” below).
🍎 Reason
let doSomething = (x) => {
Js.log(x##foo);
};
let s = {"bar": 100};
doSomething(s); // <--- Error here
This has type:
Js.t({. bar : int })
But somewhere wanted:
Js.t({.. foo : 'a })
The first object type has no method foo
Relevant: in this case, unlike what happens in Flow, the error happens at the call point, not inside the function declaration. This sounds irrelevant, but for large apps where a single function can be called from tens of different places, it’s helpful to know in which of them the error is happening. If the x
param in doSomething
is annotated explicitly in Flow though, the error shows at the call point.
TypeScript error also appears at the call point (but the paramx
in doSomething
has to be explicitly typed).
Case 3: Strict mode
🍊 Flow
There is a code coverage tool for Flow so that you can see which lines are implicitly not typed, but it doesn’t have any “strict mode” available (see issue #298). Because it has a cross-function inference engine, types are propagated through the app, so maybe there is less need for a strict mode. But there are still cases where it would be helpful to have it. Like this example from that same PR:
const keyCodes = {
esc: 27,
tab: 9,
}
function test(b: string) {
var a = keyCodes[b] // a has type 'any'
console.log(a.whatever.i.want) // runtime error
}
test('eeee')
No compile error. But runtime error Cannot read property ‘whatever' of undefined
.
🍏 TypeScript
TypeScript has the flag --noImplicitAny
to prevent situations like the one in Case 2. Because type inference doesn’t work across functions in TypeScript, the x
parameter was typed as any
which skips any type checking coming after that would apply to that parameter.
The --noImplicitAny
flag prevents that. In the same example:
const keyCodes = {
esc: 27,
tab: 9,
}function test(b: string) {
var a = keyCodes[b] // <--- Error here
console.log(a.whatever.i.want)
}test('eeee')
Error: (you will have to enable the noImplicitAny
flag on the REPL)
Element implicitly has an 'any' type because type '{ esc: number; tab: number; }' has no index signature.
const keyCodes: { esc: number; tab: number; }
🍎 Reason
In Reason there is no strict mode. Or rather, there is only one mode: up to you to decide if it’s the “strict mode”, the “relaxed mode”, or the “just fine mode” 😛
The runtime error that happened above is not possible in Reason, because there is no way to dynamically read a property from an object: keyCodes[b]
is different than keyCodes.esc
: the first behaves like dictionary or a map, while the second is more like a record, where the keys are generally known at build time.
For the first case (a dictionary) Reason provides (via BuckleScript) a type Js.Dict
. The get
function in Js.Dict
returns an option('a)
so reading an undefined value at runtime is impossible.
This would be the analog code in Reason:
let get = Js.Dict.get;
let set = Js.Dict.set;
let keyCodes = Js.Dict.empty();
set(keyCodes, "esc", 27);
set(keyCodes, "tab", 9);let test = (b) => {
let a = get(keyCodes, b);
Js.log(get(a, "whatever")); // <--- Error here
};test("eeee");
This has type:
option(int)
But somewhere wanted:
Js.Dict.t('a) (defined as Js.Dict.t('a))
Case 4: Inference across functions, part 2
In Case 2, we saw how Flow, TypeScript and Reason handled inference across functions. Let’s see another case to illustrate how relevant this is as the scenarios get more complex:
🍊 Flow
function doSomething(x) {
console.log(x);
doSomethingElse(x);
}
function doSomethingElse(x) {
console.log(x);
ohWaitOneMoreThing(x);
}
function ohWaitOneMoreThing(x) {
console.log(x.foo); // <--- Error here
}
var s = {bar: 100};
doSomething(s);
10: console.log(x.foo); // <--- Error here
^ property `foo`. Property not found in
10: console.log(x.foo); // <--- Error here
^ object literal
As mentioned in case 2, the error appears at the call point if the parameters are explicitly annotated.
🍏 TypeScript
To get the proper error with TypeScript, we enable the --noImplicitAny
flag:
function doSomething(x:{foo:string}) {
console.log(x);
doSomethingElse(x);
}
function doSomethingElse(x:{foo:string}) {
console.log(x);
ohWaitOneMoreThing(x);
}
function ohWaitOneMoreThing(x:{foo:string}) {
console.log(x.foo);
}
var s = {bar: 100};
doSomething(s); // <--- Error here
Argument of type '{ bar: number; }' is not assignable to parameter of type '{ foo: string; }'.
Property 'foo' is missing in type '{ bar: number; }'.
Notice how, with the --noImplicitAny
flag enabled, all the functions that pass the same object around have to be annotated. This restriction, especially with functional style codebases, involves some extra overhead.
🍎 Reason
let ohWaitOneMoreThing = (x) => Js.log(x##foo);let doSomethingElse = (x) => {
Js.log(x);
ohWaitOneMoreThing(x)
};let doSomething = (x) => {
Js.log(x);
doSomethingElse(x)
};let s = {"bar": 100};doSomething(s); // <--- Error here
(Side note: there is no scope hoisting in Reason / OCaml, so the functions have to be declared in inverse call order to make them available to the call point below).
This has type:
Js.t({. bar : int })
But somewhere wanted:
Js.t({.. foo : 'a })
The first object type has no method foo
Case 5: Number of “any” types in lodash
library definitions
Flow: 625
TypeScript: 692
Reason: N/A (there are no bindings for lodash afaik)
I bring this up because many times I hear “but there are no type definitions for Reason”. It’s true. As of today, there are many more types written for existing JavaScript libraries in Flow and TypeScript than there are in Reason (although the last one is progressing at a good pace).
But the truth is that, if these bindings use any
extensively as seen above, the comparison is less fair: you could have the best type inference engine on Earth, but these type definitions are worth much less, leading to situations where you think your app is properly typed (“hey, I have 100% coverage! 🎉”) but it’s actually not 😞.
As seen in Case 3, any
types can very easily lead to runtime errors. Which is honestly the most frustrating thing that could happen to anyone that has placed a bet on type systems: you invested the time they demanded to add annotations, and still suffer one of the main problems they were supposed to fix (runtime errors).
Conclusions
Flow and TypeScript implementations are very different, but both visions share the proximity to JavaScript. This decision has of course its own challenges: adding types to a language that is so dynamic is no easy feat, it’s amazing to see how powerful both have become over time.
Reason is a whole different language, and its reliance in OCaml (a language with more than 20 years history) definitely plays an important role in the nice features it provides:
- An exhaustive and accurate inference engine & type system: many less annotations required but without giving up coverage
- Good error messages: this part is mostly due to the Reason team and Jared Forsyth and Cheng Lou in particular
Besides this, it includes many expressive types to define the app data (variants!), and the build process with BuckleScript is impressively fast.
The counterpart is that Reason is much younger than Flow and TypeScript, so parts of the tooling are still being ironed out and the ecosystem is in an earlier stage too.
It also diverges from JavaScript much more than Flow and TypeScript, so it will require at least some initial investment to learn about the differences (much less after version 3 syntax changes). This might be a good or a bad thing: if you have not encountered any issues working with JavaScript and its dynamic types, then you won’t probably see the initial learning step is worth it (but mind you: learning new languages is an investment in your Knowledge Portfolio!).
If you have tried Flow and TypeScript, and you liked the typing experience but are looking for a different approach, I think Reason is at least worth 5 minutes.
In any case, I hope you enjoyed this blog post! Feel free to reach on Twitter or the comments if you have any feedback or suggestions. ✌️