Null safety – preventing most common errors
Unsafe null handling is the most common cause of runtime errors. Fortunately, strict compiler flags can all but eliminate this problem.
Developers may sometimes be annoyed by compilation errors, but most wouldn't want to forgo this safety net. After all, the sooner you're notified of potential mistakes, the easier it is to fix them.
Compile time errors are reported in IDEs and CI pipelines, so ensuring they're fixed before merging is part of a typical development workflow. Runtime errors only occur when we run the application under certain conditions, so they're much harder to catch, and such bugs could slip through to production and affect end users.
In this article, I will describe how to take advantage of the TypeScript compiler in order to prevent frequent runtime errors. I will focus on the most common type of error, which is unsafe handling of potentially nullish values.
Problems related to null are by no means exclusive to JavaScript and TypeScript, however. In fact, null has been a major pain point for countless programmers for well over half a century.
The Billion-Dollar Mistake
All the way back in 1965, Tony Hoare invented null references for the ALGOL W programming language. In 2009, he called this his "billion-dollar mistake":
I call it my billion-dollar mistake. It was the invention of the null reference in 1965. […] This has led to innumerable errors, vulnerabilities, and system crashes, which have probably caused a billion dollars of pain and damage in the last forty years.
Most popular programming languages include null pointers in some shape or form.
Unsurprisingly, null pointer errors invariably show up as one of the most common weaknesses of such languages.
In Java, the NullPointerException
tops the list of most common errors in production environments.
C#'s NullReferenceException
and segmentation faults in C and C++ share similar levels of infamy.
JavaScript suffers from this problem as well, as demonstrated by Rollbar's list of Top 10 JavaScript errors from 1000+ projects, aggregated across different companies who use their error tracking and monitoring services. 7 out of the top 10 errors are caused by unhandled nullish values:
- reading a property or calling a method on an undefined/null object
- #1:
TypeError: Cannot read property '...' of undefined
(Chrome) - #2:
TypeError: 'undefined' is not an object (evaluating '...')
(Safari) - #3:
TypeError: 'null' is not an object (evaluating '...')
(Safari)
- #1:
- calling an undefined function/method
- #5:
TypeError: Object doesn't support property or method '...'
(IE) - #6:
TypeError: ... is not a function
(Chrome)
- #5:
- reading length property of an undefined variable
- #8:
TypeError: Cannot read property 'length' of undefined
(Chrome)
- #8:
- trying to set property of undefined
- #9:
TypeError: Cannot set property '...' of undefined
- #9:
Compiler to the rescue
One of the reasons why null pointer errors became so common is that nullability checks used to be entirely the programmer's responsibility. Compilers would assist with type checking, but because null wasn't considered part of the type system, potential null dereferences were ignored. Yet programmers would create null pointers all the time – either explicitly (null could be assigned to just about anything) or implicitly (not initialising a variable) – so null handling was frequently forgotten about.
Fortunately, times have changed for the better. Modern compilers have integrated nullability checks in recent versions. In fact, they've generally made it a default setting.
Java has been replaced by Kotlin as Google's preferred language for Android development. Kotlin fixes a number of issues that Java suffers from, with null safety chief among them. In the C#/.NET ecosystem, nullable reference types were introduced in C# 8, and nullable context has been enabled by default since C# 10 and .NET 6. Similarly, the Dart programming language (used in Google's Flutter framework) introduced sound null safety in version 2.12 and even went so far as to discontinue non-null-safe mode in version 3.
TypeScript has gone through this evolution too.
The strictNullChecks
compiler flag was released in version 2.0, and is included in the strict
mode options, which are enabled for most new TypeScript projects nowadays.
Version 4.1. later introduced the noUncheckedIndexedAccess
flag, which further enhances null safety, although most projects don't use it yet – more on that later.
TypeScript's strict null checks work in much the same way as in the languages mentioned above, although there is a little twist – JavaScript goes one step beyond most languages in having two different types of null values.
What's the deal with null
and undefined
?
You may have come across the term "nullish".
It's a handy way of referring to both null
and undefined
at once.
The fact that JavaScript has these two distinct ways of representing the same thing is inconvenient at best, and I've yet to find some good justification for why the language was designed this way.
There is a small technical difference.
While both null
and undefined
can be assigned explicitly, undefined
is also the implicit value of uninitialized variables, non-existant object properties and missing function arguments.
Let's say you assign { x: null, y: undefined }
to an object.
If you access its x
and y
properties, then the values will be null
and undefined
, respectively.
If you were to access a non-existant property like z
, the value will be undefined
.
So an undefined
value can be both explicit (y
) or implicit (z
).
There is no distinction when reading values, but if you check the object keys (e.g. using the in
operator) then you'd get different results for y
and z
.
There is no well-established semantic difference between null
and undefined
.
They both represent an empty value, and which representation to use is basically an arbitrary decision.
There is little to no consistency in when you can expect to encounter one or the other.
For example, a "not found" array item from index access ([i]
) or find
method is represented as undefined
, while a "not found" DOM element from document.getElementById
or document.querySelector
is represented as null
.
And just in case this wasn't enough of a mess, beware of using the typeof
operator to check for null.
It works fine for undefined
values, returning the string 'undefined'
as you might expect.
But there is an infamous bug in JavaScript which causes typeof null
to return 'object'
.
To preserve backwards compatibility, this bug had to be standardized as a language feature.
Seriously, that's not a joke.
In the interest of staying stane, I recommend handling both null
and undefined
the same way.
Fortunately, this is simple to implement.
Newer JavaScript operators like ??
and ?.
make no distinction between the nullish values.
Both null
and undefined
are considered falsy when used in conditions.
And if you need to dinstinguish them from other falsy values (e.g. false
, ''
, 0
, NaN
), then loose equality checks are quite convenient.
Whereas x === null
and x === undefined
(or typeof x === 'undefined'
as some people write it, though I've never understood why) evaluate differently for null
and undefined
, x == null
is true only for null
and undefined
.
It's the only good use case for loose equality I know of, aside from nullish values you should always prefer strict equality.
If you're using ESLint, I suggest configuring the eqeqeq
rule as ["error", "always", { "null": "never" }]
to enforce this pattern.
Strict null checks
In order to demonstrate safe null handling practices in more detail, I'll go through a few code examples. I'll be referring to the following type definitions:
Let's start with a simple function which formats the comment author's name:
What if the comment's author is missing? The property is marked as optional in the type definition after all, meaning we may receive anonymous comments.
Without strictNullChecks
enabled, TypeScript will pretend that null
and undefined
don't exist, and will happily compile without reporting any errors (you should picture TypeScript in non-strict mode as the see no evil monkey 🙈).
Any anonymous comment will therefore result in a Cannot read properties of undefined (reading 'firstName')
runtime error when the function is called.
However, with strictNullChecks
enabled, TypeScript becomes far more observant.
It correctly identifies that 'comment.author' is possibly 'undefined'
for both .firstName
and .lastName
property access expressions.
Now that the compiler's helping us by pinpointing the possible error, it's our job as the programmer to fix it.
A reasonable way to handle this scenario might be to return 'Anonymous'
or undefined
from the function.
A simple if
-block will do the job:
Now that we've handled this case safely in runtime, the compile-time error has also gone away.
This is thanks to TypeScript's control flow analysis.
By statically analysing the code, TypeScript is able to determine that the final return statement is unreachable if comment.author
is undefined
, and so narrows the type from UserModel | undefined
to UserModel
.
Type narrowing works for many kinds of control flow constructs, including if
/else
branches, the ternary operator (? :
), switch
blocks and lazy evaluation with &&
/||
logical operators.
So the following alternatives would also work:
ES2020 introduced a couple of operators which are tailor made to make safe null handling simple and declarative – optional chaining (?.
) and nullish coalescing (??
).
Let's use them to implement a function which safely returns the comment author's avatar if it exists.
The first version returns undefined
for anonymous comments, while the second version falls back on some default image.
Type predicates
Things get a bit more complicated when your null handling crosses function boundaries.
Let's use Array.filter
as an example.
In the following function, we receive an array of comments and extract all of their authors using Array.map
.
But we don't want the resulting array to include undefined
s from anonymous comments, so we remove them using Array.filter
.
Logically, the .filter(author => author != null)
expression should narrow the type from (UserModel | undefined)[]
(result of .map(comment => comment.author)
) to UserModel[]
.
But how can TypeScript infer that?
It's more complicated for a compiler to analyze this control flow, as it has to somehow recognize how the null check from filter
's predicate function impacts types in the caller function.
Type narrowing across function boundaries relies on so-called type predicates.
A type predicate is a special return type of a function, which promotes a boolean
return value to a type guard of some function parameter.
The syntax of this return type is x is T
, where x
is the name of a function parameter and T
is what type that parameter should be narrowed to if the function returns true
.
TypeScript 5.5 shipped inferred type predicates, so latest TypeScript versions are able to automatically narrow the type in our example above (Array.filter
has an overload which accepts a type predicate).
For older versions, the type predicate needs to be added explicitly to avoid a Type '(UserModel | undefined)[]' is not assignable to type 'UserModel[]'
compiler error:
There are some limitations to TypeScript's ability to infer type predicates. For example, if we were to slightly refactor our function so that we filter the comments before extracting the authors, then the type narrowing no longer works1, even though logically there should be no difference.
In this case, a user-defined type predicate is needed to satisfy the compiler:
One thing to keep in mind when definining your own type predicates is that you're responsible for making sure it's logically sound. The compiler doesn't check that the return statement matches the type guard, aside from when it can infer the predicate itself (in which case you probably don't need to define your own anyway).
Watch out for type casting
TypeScript is designed to be incrementally adoptable, so it lets you be as strict or loose with your types as you want. In many ways this flexibility is a great strength of the language, but it can be a weakness also. The language includes several potential footguns, which should be used sparingly if you care about type-safety.
The any
type is already well-known to be unsafe, so instead I will focus on a lesser known safety hole – type casting.
Unlike a regular type annotation (x: T
), type casting (x as T
, or less commonly <T>x
) overrides the type that TypeScript would safely infer otherwise.
Whereas in other languages (e.g. in C#) type casting actually converts the value, in TypeScript the as
keyword has no effect on runtime behaviour.
It's just a way of telling the compiler: "I know something you don't, this is what the type is, trust me."
For example, when casting an object, TypeScript will not report required properties that are missing, trusting that the programmer knows better.
If the programmer's assumption is incorrect, then you may encounter an unexpected runtime error.
The code sample below compiles, but will result in an uncaught TypeError: Cannot read properties of undefined (reading 'toUpperCase')
:
You may also come across the non-null assertion operator (!
), which is a shorthand for casting a nullable type to its non-nullable equivalent.
As its merely an alternative syntax for type casting, it shares the same pitfalls.
In the following example, TypeScript won't complain about accessing a property of a potentially undefined object, so any anonymous comment will result in an uncaught TypeError: Cannot read properties of undefined (reading 'email')
:
Because the !
is easily overlooked in code review, I suggest you include the @typescript-eslint/no-non-null-assertion
rule in your ESLint configuration.
Not that refactoring comment.author!.email
to (comment.author as UserModel).email
is any better, mind you, in fact it's potentially even less safe (the @typescript-eslint/non-nullable-type-assertion-style
rule prevents this).
If the type cast can't be avoided, then it's best to go with an // eslint-disable-next-line
comment.
In non-production code (e.g. tests), where preventing runtime errors is a lesser concern, this rule can become quite inconvenient, so I typically disable it for these types of files (e.g. *.spec.ts
).
Unchecked index access
Even with strict mode enabled, the type checker still has one blind spot.
When you access object keys that don't exist, standard JavaScript behaviour is to return undefined
.
This applies for out of range array indexes as well (arrays are objects, after all).
However, TypeScript doesn't treat such expressions to be possibly undefined, unless you've set "noUncheckedIndexedAccess": true
in your tsconfig.json
.
More likely than not, you haven't.
Let's illustrate the problem with a final code example. We'll implement a function which receives a comment ID and an object which maps comment IDs to an array of comments which represent replies. The function should return a summary text for the given comment, which includes the number of replies and the date of the last reply.
Now, consider what would happen if, for whatever reason, the comment ID wasn't one of the keys in the replies object.
Well, repliesMap[id]
would be undefined
, and since we assign that to the replies
variable and then access replies.length
on the next line, we'll get a Cannot read properties of undefined (reading 'length')
runtime error (#8 in Rollbar's list).
What if the key is present, but the matching replies array is empty?
In this case, replies.length
would be 0, so replies[replies.length - 1]
will be equivalent to replies[-1]
and therefore undefined
, and the subsequent .timestamp
property access will cause a runtime error of Cannot read properties of undefined (reading 'timestamp')
(#1 in Rollbar's list).
Clearly, this code is unsafe, and it would really help us out if the compiler could stick its head out of the sand and warn us about these problems.
Enabling noUncheckedIndexedAccess
does the trick.
TypeScript wises up to the fact that arrays and records don't include keys for every possible string
or number
, and infers each index access expression as potentially undefined
.
As a result, we get 4 different Object is possibly 'undefined'
compiler errors.
Let's satisfy the compiler by applying some of the null handling patterns described above:
Our code passes type checking and gracefully handles the missing/empty replies scenario. How useful it is to have a smart compiler which guides us towards making our code less error-prone!
Null safety for your TypeScript codebase
If you're starting a new project using TypeScript, then I think it's a no-brainer to configure "strict": true, "noUncheckedIndexedAccess": true
compiler options from the very start.
Most modern project initializers enable strict
mode by default, but they typically forget about noUncheckedIndexedAccess
, so you'll probably have to enable this flag in your tsconfig.json
yourself.
If your project already includes a lot of TypeScript code written with less strict compiler options, there's a strong possibility that enabling strict
and/or noUncheckedIndexedAccess
will "introduce" new compiler errors.
Ideally, you would fix them using the safe null handling patterns I described above.
But if there are a lot of errors, fixing them all in one go may not be manageable, and you should go with a gradual approach instead.
If you need guidance, I recommend reading about how the VSCode team incrementally adopted strict null checks in their codebase.
Once you have the compiler flags configured, all that remains is to apply safe null handling practices in a disciplined manner.
Resist the temptation to override the type checker with type casting, and avoid any
s like the plague.
Let TypeScript guide you, and you will not be lead astray.
Unsafe null handling is an old and extremely common problem, but with the right tools and mindset, it is easily preventable.
This article is adapted from my Null safety in TypeScript and Angular talk at an Angular Vienna meetup in 2022.
The recording (incl. slides) includes some extra tips for Angular developers – the main takeaway is you should make sure the strictTemplates
flag is enabled (default since version 12), so that strictNullChecks
apply to your components' HTML templates as well.
Footnotes
-
At time of writing, latest TypeScript version is 5.6. ↩