TL;DR:
- Compiler does not understand control flow in closure / callback function.
- In flow based type analysis, copmiler will be either optimistic or pessimistic. TypeScript is optimistic.
- You can usually workaround (3) by using
const
orreadonly
This is a long due introduction for TypeScript’s flow sensitive typing (also known as control flow based type analysis) since its 2.0 release. It is so unfortunate that no official documentation is in TypeScript’s website for it (while both flow and kotlin have!). 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 like if
or while
. For example, you can dynamically check the truthiness of a nullable variable and if it isn’t null, 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-effectful nature prevents compiler from inferring control flow when function call kicks in. Let’s see an example.
1 |
|
Without knowing what makeSideEffect
is, we cannot guarantee variable a
is still number
. Side effect 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 compiler to infer what makeSideEffect
does since we can provide the source of the function.
However this is not practically feasible because of ambient function and (possibly polymorphic) recursion. Compiler will be trapped in infinite loops if we instruct it to infer arbitrary deep functions, as 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 relevant side effect: e.g. assignment like
a = null
. We call this optimistic. - Assume every function does have side effect. We call this strategy pessimistic.
Spoiler: TypeScript uses 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.
1 | var a: string | number = 42 // smart cast to number |
As a developer, you can easily figure out that string
will be output to console because setTimeout
will call its function argument asynchronously, after assigning string
to a
. Unfortunately, this knowledge is not accessible to compiler. No keyword will tell compiler whether callback function will be called immediately, nor static analysis will tell the behavior of a function: setTimeout
and forEach
is the same in the view of compiler.
So the following example will not compile.
1 | var a: string | number = 42 // smart cast to number |
Note: compiler will still inline control flow analysis for IIFE(Immediately Invoked Function Expression).
1 | let x: string | number = "OK"; |
In the future, we might have a keyword like immediate
to help compiler reasoning more about control flow. But that’s a different story.
Now let’s review the strategies for function call.
Optimistic Flow Sensitive Typing
Optimistic flow typing assume a function without side-effect that changes a variable’s type. TypeScript chooses this strategy in its implementation.
1 | var a: number | null |
This assumption usually works well if code observes immutable rule. On the other hand, a stateful program will be tolled with the tax of explicit casting. One typical example is scanner in a compiler. (Both Angular template compiler and TypeScript itself are victims).
1 | // suppose we are tokenizing an HTML like language |
Such bad behavior also occurs on fields.
1 | // A function takes a string and try to parse |
An alternative here is returning a new result object so we need no inline mutation. But in some performance sensitive code path might we want 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 sometimes is also unsound: a compiler verified program will cause runtime error. We can easily construct a function which reassigns a variable to an object of different type and uses it as of the original type, and thus a runtime error!
1 | class A { a: string} |
The above examples might leave to you a impression that compiler does much bad when doing optimistic control flow inference. In practice, however, a well architected program with disciplined control of side effect will not suffer much from compiler’s naive optimistism. Presumption of immutability innocence will save you a lot type casting or variable rebinding found in pessimistic flow sensitive typing.
Pessimistic Flow Sensitive Typing
A pessimistic flow analysis places 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 is matching with previous control flow analysis.
Examples in this section are crafted to be runnable under both TS and flow-type checker.
Note, only flow-type checker will produce error because flow is more pessimistic/strict than TypeScript.
1 | declare function log(obj: any): void |
Alas, pessimistism also breaks fields. Example taken from stackoverflow.
1 | declare function assert(obj: any): void |
These false alarms root in the same problem as in optimistic strategy: compiler/checker has no knowledge about a function’s side effect. To work with a pessimistic compiler, one has to assert/check repeatedly so 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
Because a const
identifier will never change its type.
1 | const a: string | number = someAPICall() // smart cast to number |
And using const
will provide you runtime safety or bypass pessimistic checker.
1 | function fn(x: string | null) { |
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 in your way but make a pathway to a safer code base!