Get the best of TypeScript Control Flow Analysis

Charly Poly
Charly Poly
Contributor

Dec 2, 2021

TypeScript is now widely used across all web stacks; however, it can still be hard to grasp some advanced concepts and types or stay away from the any type.

Fortunately, each new TypeScript release improves the compiler to bring better type inference, even on simple use cases.

This article will show some simple type construction patterns, code writing habits, and compiler options that you can use to improve your application's type inference without increasing its complexity.

Coding best practices for improved type inference

Getting the best of TypeScript type inference does not necessarily require advanced types or the use of the as operator. By changing some coding habits, we can get the best from TypeScript with its Control Flow Analysis.

Control Flow Analysis is a core TypeScript feature that analyzes the code to get the best type inference depending on variable usages; this feature improved over the years, resulting in some impressive type inference in recent TypeScript versions.

Basic narrowing

TypeScript does its best to follow JavaScript syntax to narrow (the process of resolving to a more precise subtype) a given variable type given its usage.

A common use case is truthiness narrowing:

Here, TypeScript Control Flow Analysis infers p.address as not undefined inside the if block, and undefined after it. Such a condition works for any values that would coerce to falsy: 0, NaN, "", null and undefined.

A more advanced pattern is the equality narrowing:

TypeScript can infer that both arguments are of type string inside the if block thanks to the strict equality condition.

Finally, the famous typeof and instanceof operator usages are also analyzed by TypeScript to bring better type inference:

formatDate() is a complete example of leveraging the basics of TypeScript Control Flow Analysis by using a combination of conditions with some typeof and instanceof type-guard operators.

Let's now see more advanced type narrowing patterns.

Narrowing based on multiple variables and conditions

The fact that TypeScript can infer variable types by looking at simple conditions is not very impressive. However, looking at the example below, you will see that TypeScript Control Flow Analysis can infer a variable type by looking at up to 5 levels of indirections, even if mixed with other unrelated conditions:

No need to specify our variable directly in conditions: TypeScript will do its best to infer any variable type given its context. And while this is impressive, it only takes into account scalar types.

Let's see some advanced examples applied to objects.

Write custom type guards

The following snippet of code is common among applications:

Here, TypeScript cannot infer that an object having a plan === "premium" property is of type PremiumUser, leaving us with having to use the as operator. However, using the as operator every time a premium user is expected would be cumbersome. Also, the as operator asks TypeScript to trust our type coercion, breaking the type safety.

For this reason, TypeScript allows defining functions that will help narrow custom types such as interfaces. These functions are called User-defined type guards:

Our isPremiumUser() function uses the specific return type keyword is, which indicates to TypeScript that the user argument is of type PremiumUser if the function's return value is truthy.

Here is our updated getPremiumOptions() function example:

Like the previous examples of type narrowing using typeof or simple conditions, our custom isPremiumUser() type guard function will help us narrow any User | Premium variable in our application—without using the as operator.

A famous example of type guard is the lodash's isString() function defined as follows:

This explains why developers can use most of the lodash lang functions for type narrowing:

In the first condition block, name is narrowed to the string type (because isString() is type-guard function). In the second condition block, price is narrowed to never because it could never be of type string.

Type guards are helpful to improve complex type inference; however, they are not always necessary. When given types share some common properties, we can narrow types without defining custom type guards functions.

Improving type inference on interfaces without writing type guards

Narrowing the type of interfaces does not always require writing type guard functions.

Sometimes, just updating the structure of the interfaces to leverage the Discriminated Union pattern will be way more effective (more on TypeScript unions here)

Our improved PremiumUser type

Discriminated Union is a pattern that consists of constructing interfaces sharing some common properties using a specific shape. It helps TypeScript narrow down the proper interface based on a given property value, as follows:

Because both BasicUser and PremiumUser share a common property plan, TypeScript can narrow down the type of the user variable based on the plan property value.

A Discriminated Union pattern consists of three ingredients:

  • Types that have a common, singleton type property—the discriminant.
  • The plan property, shared by BasicUser and PremiumUser
  • Then, a type alias that takes the union of those types—the union.
  • Our user variable (as an inline type)
  • Finally, a type guard or condition on the discriminant.
  • Our if/else block

Advanced Discriminated Union pattern usages

Since TypeScript 4.4, our condition on the plan property does not have to be directly accessed on the user variable; TypeScript can also narrow user's type from destructured properties, as follows:

And, since the brand new TypeScript 4.5 release, the Discriminated Union common property can be defined using template string types, as follows:

Combining the Discriminated Union pattern with template string types gives a lot of flexibility in the type definition, thanks to the automatic type inference from the code by the Control Flow Analysis feature.

Getting the best of the TypeScript's compiler

Adopting new coding habits to leverage TypeScript Control Flow Analysis's type narrowing and building smarter types with the Discriminated Union pattern will improve your application's code quality. However, those changes won't significantly improve your application type checking quality without using the proper TypeScript compiler configuration.

Let's take another look at our isPremiumUser() type-guard, which is called this time with a possibly undefined user variable:

With the default TypeScript configuration, no type-checking error will be raised for calling isPremiumUser() with an undefined value. However, such scenarios will break at runtime.

To prevent any surprise at runtime, let's review together three TSConfig options that will increase your type's strength. Unlike previous best practices, any compile option change may trigger a dozen to a hundred TypeScript errors across your codebase (due to stricter type-checking). Keep in mind that such changes have to be conducted as planned code migrations.

TypeScript Strict mode

Strict mode is the most famous of the hundred of TSConfig options (TypeScript compiler options).

Adding "strict": "true" to your tsconfig.json will enable eight other TSConfig options.

Two of them will significantly improve type inference in your projects:

noImplicitAny

TypeScript is primarily designed for gradual adoption, which means that type annotations are optional. When function arguments are missing type annotations, TypeScript will do its best to infer their types based on usage:

When checking on our multiply(a, b) function, TypeScript is smart enough to know that the * operator will coerce any given values to a number.

However, TypeScript is not able to resolve the return type of our sum(a, b) function below (because the additive operator + can be used for both addition and string concatenation):

Updating your tsconfig.json to enable "noImplicitAny": "true" or "strict": "true" will tell TypeScript to raise an error every time it cannot infer the type of an argument or a variable missing a type annotation:

strictNullChecks

Again, to enable gradual adoption, TypeScript is trying to stick as much as possible to the behavior of JavaScript. As we saw with our isPremiumUser() example, TypeScript is not checking for null or undefined types in variables and object properties:

This TypeScript compiler behavior can result in fatal unexpected runtime errors, especially when working with some GraphQL clients (most GraphQL results objects have nullable properties by default).

Updating your tsconfig.json to enable strictNullChecks”: “true or "strict": "true" will ask TypeScript to check for null and undefined types in all variables and object properties, catching potential runtime errors earlier:

Enabling strict mode on your TypeScript project is a must-have to prevent null or undefined access at runtime and stop the spread of unintentional any types in your application.

Now, let's move to some lesser known options of the TypeScript compiler.

Force checking index accesses

TypeScript has an interesting way of handling type inference on arrays. By default, TypeScript will assume that every array access (also called index access) will return a value:

If your project's business logic is heavily relying on array accesses, you might realize that most of your types are pretty weak.

For this reason, you should consider enabling the noUncheckedIndexedAccess option in your project (by adding "noUncheckedIndexedAccess": "true" to your tsconfig.json).

Doing so, every time a developer accesses an array, TypeScript will assume that the value could be undefined:

Prevent optional properties to get an undefined value

By default, adding the optional modifier to an object property also adds the undefined type to the existing type:

This allows any other developer to do the following:

While most of the time assigning to a property an undefined value is not a big deal, it can lead to an unexpected behavior:

To prevent TypeScript from allowing optional properties to receive undefined values, we can add the exactOptionalPropertyTypes TSConfig option, which will raise an appropriate error:

We now have a TypeScript compiler configuration that will prevent any misuse of your application types.

Conclusion

Getting strong types and better type inference doesn't necessarily require advanced types and as usages everywhere.

Simply leveraging TypeScript Control Flow Analysis by appropriately narrowing types and defining type guard functions can significantly improve the type inference.

The same goes for the construction of interfaces sharing common properties (such as Redux actions or User interfaces). Using the Discriminated Union pattern will allow other developers to use the common property in conditions to narrow down the expected type.

Finally, all best type-building and coding best practices are worthless without the proper TypeScript compiler configuration (TSConfig).

Enabling the strict mode and making index and optional property access less optimistic will give you back the control of your TypeScript types.

Reader

Charly Poly
Charly Poly
Contributor
Dec 2, 2021
Copied