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
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.
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:
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
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
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:
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
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
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:
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.
planproperty, shared by
Then, a type alias that takes the union of those types—the union.
uservariable (as an inline type)
Finally, a type guard or condition on the discriminant.
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
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).
"strict": "true" to your
tsconfig.json will enable eight other TSConfig options.
Two of them will significantly improve type inference in your projects:
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
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):
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:
isPremiumUser() example, TypeScript is not checking for
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).
tsconfig.json to enable
strictNullChecks”: “true or
"strict": "true" will ask TypeScript to check for
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
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
Doing so, every time a developer accesses an array, TypeScript will assume that the value could be
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.
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.