SoatDev IT Consulting
SoatDev IT Consulting
  • About us
  • Expertise
  • Services
  • How it works
  • Contact Us
  • News
  • July 13, 2023
  • Rss Fetcher
Photo by kazuend on Unsplash

TL;DR

  1. TS compiler does not understand control flow in closure/callback function.
  2. In flow-based type analysis, the compiler will be either optimistic or pessimistic. TypeScript is optimistic.
  3. 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:

  1. Assume every function does not have a relevant side effect. For example, assignment like a = null. We call this optimistic.
  2. 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.

Previous Post
Next Post

Recent Posts

  • Naukri exposed recruiter email addresses, researcher says
  • Khosla Ventures among VCs experimenting with AI-infused roll-ups of mature companies
  • Presidential seals, ‘light vetting,’ $100,000 gem-encrusted watches, and a Marriott afterparty
  • Zoox issues second robotaxi software recall in a month following collision 
  • Landa promised real estate investing for $5. Now it’s gone dark.

Categories

  • Industry News
  • Programming
  • RSS Fetched Articles
  • Uncategorized

Archives

  • May 2025
  • April 2025
  • February 2025
  • January 2025
  • December 2024
  • November 2024
  • October 2024
  • September 2024
  • August 2024
  • July 2024
  • June 2024
  • May 2024
  • April 2024
  • March 2024
  • February 2024
  • January 2024
  • December 2023
  • November 2023
  • October 2023
  • September 2023
  • August 2023
  • July 2023
  • June 2023
  • May 2023
  • April 2023

Tap into the power of Microservices, MVC Architecture, Cloud, Containers, UML, and Scrum methodologies to bolster your project planning, execution, and application development processes.

Solutions

  • IT Consultation
  • Agile Transformation
  • Software Development
  • DevOps & CI/CD

Regions Covered

  • Montreal
  • New York
  • Paris
  • Mauritius
  • Abidjan
  • Dakar

Subscribe to Newsletter

Join our monthly newsletter subscribers to get the latest news and insights.

© Copyright 2023. All Rights Reserved by Soatdev IT Consulting Inc.