TL;DR
- TS compiler does not understand control flow in closure/callback function.
- In flow-based type analysis, the compiler will be either optimistic or pessimistic. TypeScript is optimistic.
- You can usually workaround (3) by using const or readonly
This is a long-due introduction to TypeScript’s flow-sensitive typing (also known as control flow-based type analysis) since its 2.0 release.
It is so unfortunate that TypeScript only has minimal official documentation website for it (while both flow and Kotlin have detailed examples!). But if you dig the issue list earnestly enough, you will always find some hidden gems there!
To put it short, a variable’s type in a flow-sensitive type system can change according to the control flow constructs like if or while. For example, you can dynamically check the truthiness of a nullable variable, and if it isn’t null, the compiler will automatically cast the variable type to non-null. Sweet?
What can be bitter here? TypeScript is an imperative language like JavaScript. The side-effect nature prevents the compiler from inferring control flow when a function call kicks in. Let’s see an example below:
let a: number | null = 42
makeSideEffect()
a // is a still a number?
function makeSideEffect() {
// omitted...
}
Without knowing what makeSideEffect is, we cannot guarantee that variable a is still number. Side effects can be as innocuous and innocent as console.log(‘the number of life’, 42), or as evil as a billion-dollar mistake like a = null, or even a control-flow entangler: throw new Error(‘code unreachale’).
One might ask the compiler to infer what makeSideEffect does since we can provide the function’s source.
However, this is not feasible because of ambient function and (possibly polymorphic) recursion. The compiler will be trapped in infinite loops if we instruct it to infer arbitrary deep functions as a halting problem per se.
So a realistic compiler must guess what a function does by a consistent strategy. Naturally, we have two alternatives:
- Assume every function does not have a relevant side effect. For example, assignment like a = null. We call this optimistic.
- Assume every function does have side effects. We call this strategy pessimistic.
Spoiler: TypeScript uses an optimistic strategy.
We will walk through these two strategies and see how they work in practice.
But before that, let’s see some common gotchas in flow-sensitive typing.
Closure / Callback
Flow-sensitive typing does not play well with callback functions or closures. This is explicitly mentioned in Kotlin’s document.
var local variables — if the variable is not modified between the check and the usage and is not captured in a lambda that modifies it
Consider the following example:
var a: string | number = 42 // smart cast to number
setTimeout(() => {
console.log(typeof a) // what should be print?
}, 100)
a = 'string'
As a developer, you can easily figure out that string will be output to the console because setTimeout will call its function argument asynchronously after assigning a string to a. Unfortunately, this knowledge is not accessible to the compiler. No keyword will tell the compiler whether the callback function will be called immediately, nor will static analysis tell the behavior of a function: setTimeout and forEach is the same in the view of the compiler.
So the following example will not compile.
var a: string | number = 42 // smart cast to number
someArray.forEach(() => {
a.toFixed() // error, string | number does not have method `toFixed`
})
Note: the compiler will still inline control flow analysis for IIFE(Immediately Invoked Function Expression).
let x: string | number = "OK";
(() => {
x = 10;
})();
if (x === 10) { // OK, assignment in IIFE
}
In the future, we might have a keyword like immediate to help the compiler reason more about control flow. But that’s a different story.
Now, let’s review the strategies for function calls.
Optimistic Flow Sensitive Typing
Optimistic flow typing assumes a function without side-effect that changes a variable’s type. TypeScript chooses this strategy in its implementation.
var a: number | null
a = 42 // assign, now a is narrowed to type `number`
sideEffect() // assume nothing happens here
a.toFixed() // a still has `number` type
This assumption usually works well if the code observes immutable rules. On the other hand, a stateful program will be tolled with the tax of explicit casting. One typical example is a scanner in a compiler. (Both Angular template compiler and TypeScript itself are victims).
// suppose we are tokenizing an HTML like language
enum Token { LeftBracket, WhiteSpace, Letter ... }
let token = Token.WhiteSpace;
function nextToken() {
token = readInput(); // return a Token
}
function scan() {
// token here is WhiteSpace
while (token === Token.WhiteSpace) {
// skip white space, a common scenario
nextToken()
}
if (token === Token.LeftBracket) { // error here
// compiler thinks token is still WhiteSpace, optimistically but wrongly
}
}
Such bad behavior also occurs in fields.
// A function takes a string and try to parse
// if success, modify the result parameter to pass result to caller
declare function tryParse(x: string, result: { success: boolean; value: number; }): void;
function myFunc(x: string) {
let result = { success: false, value: 0 };
trySomething(x, result);
if (result.success === true) { // error!
return result.value;
}
return -1;
}
An alternative here is returning a new result object, so we need no inline mutation. But in some performance-sensitive code paths, we might want to parse a string without new object allocation, which reduces garbage collection pressure. After all, mutation is legal in JavaScript code, but TypeScript fails to capture it.
Optimistic flow analysis is sometimes unsound: a compiler-verified program will cause a runtime error. We can easily construct a function that reassigns a variable to an object of a different type and uses it as the original type, thus a runtime error!
class A { a: string}
class B { b: string}
let ab: A | B = new A
doEvil()
ab.b.toString() // booooooom
function doEvil() {
ab = new B
}
The above examples might leave you with the impression that the compiler does much bad when doing optimistic control flow inference. In practice, however, a well-architected program with disciplined control of side effects will not suffer much from the compiler’s naive optimism. Presumption of immutability innocence will save you a lot of typecasting or variable rebinding found in pessimistic flow-sensitive typing.
Pessimistic Flow Sensitive Typing
A pessimistic flow analysis places the burden of typing proof on programmers.
Every function call will invalidate previous control flow-based narrowing. (Pessimistic possibly has a negative connotation, conservative may be a better word here). Thus programmers have to re-prove variable types matching with previous control flow analysis.
Examples in this section are crafted to be runnable under both TS and flow-type checkers. Note that only the flow-type checker will produce an error because the flow is more pessimistic/strict than TypeScript.
declare function log(obj: any): void
let a: number | string = 42
log(a) // invalidation!
a.toFixed() // error, a's type is reverted to `number | string`
// To work around it, you have to recheck the type of `a`
typeof a === 'number' && a.toFixed() // works
Alas, pessimism also breaks fields. Here’s an example from Stack Overflow.
declare function assert(obj: any): void
class TreeNode<V, E> {
value: V
children: Map<E, TreeNode<V,E>> | null
constructor(value: V) {
this.value = value
this.children = null
}
}
function accessChildren(tree: TreeNode<number, string>): void {
if (tree.children != null) {
assert(true) // negate type narrowing
tree.children.forEach((v,k) => {}) // error!
}
}
These false alarms root in the same problem as the optimistic strategy: the compiler/checker has no knowledge about a function’s side effects. To work with a pessimistic compiler, one has to assert/check repeatedly to guarantee no runtime error will occur. Indeed, this is a trade-off between runtime safety and code bloat.
Workaround
Sadly, no known panacea for flow-sensitive typing. We can mitigate the problem by introducing more immutability.
Using const
A value declared by const will never change its type because no assignment will happen.
const a: string | number = someAPICall() // smart cast to number
if (typeof a === 'string') {
setTimeout(() => {
a.substr(0) // success, `const` identifier will not lose its narrrowed type
}, 100)
}
Using const will provide you with runtime safety or bypass the pessimistic checker.
function fn(x: string | null) {
const y = x
function assert() {
// ... whatever
}
if (y !== null) {
console.log(y.substr(0)); // no error, no crash
}
}
The same should apply to readonly, but current TypeScript does not seem to support it.
Conclusion
Flow-sensitive typing is an advanced type system feature. Working with control flow analysis smoothly requires programmers to control mutation effectively.
Keeping mutation control in your mind. Flow-sensitive typing will not be in your way but will make a pathway to a safer code base!
Grok Control Flow-Based Analysis in TypeScript was originally published in Better Programming on Medium, where people are continuing the conversation by highlighting and responding to this story.