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.
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.
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.
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.
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.
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)
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 byBasicUser
andPremiumUser
- 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
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.
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.
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:
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:
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.
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
:
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.
Reader