DX Guardian logoDX Guardian
Image for Null safety – preventing most common errors

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.

"You are too drunk to drive." – Compile time error vs Runtime
error

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:

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:

type CommentModel = {
  id: string;
  text: string;
  timestamp: number;
  author?: UserModel;
};
 
type UserModel = {
  email: string;
  firstName: string;
  lastName: string;
  avatarUrl?: string;
};

Let's start with a simple function which formats the comment author's name:

function getCommentAuthorName(comment: CommentModel): string {
  return `${comment.author.firstName} ${comment.author.lastName}`;
}

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:

function getCommentAuthorName(comment: CommentModel): string {
  if (comment.author == null) {
    return 'Anonymous';
  }
  return `${comment.author.firstName} ${comment.author.lastName}`;
}

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:

function getCommentAuthorName(comment: CommentModel): string {
  return comment.author == null
    ? 'Anonymous'
    : `${comment.author.firstName} ${comment.author.lastName}`;
}
function getCommentAuthorName(comment: CommentModel): string | undefined {
  return (
    comment.author && `${comment.author.firstName} ${comment.author.lastName}`
  );
}

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.

function getCommentAvatar(comment: CommentModel): string | undefined {
  return comment.author?.avatarUrl;
}
function getCommentAvatar(comment: CommentModel): string {
  return comment.author?.avatarUrl ?? DEFAULT_AVATAR_URL;
}

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 undefineds from anonymous comments, so we remove them using Array.filter.

function extractAuthors(comments: CommentModel[]): UserModel[] {
  return comments
    .map(comment => comment.author)
    .filter(author => author != null);
}

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:

function extractAuthors(comments: CommentModel[]): UserModel[] {
  return comments
    .map(comment => comment.author)
    .filter((author): author is UserModel => author != null);
}

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.

function extractAuthors(comments: CommentModel[]): UserModel[] {
  return comments
    .filter(comment => comment.author != null)
    .map(comment => comment.author);
}

In this case, a user-defined type predicate is needed to satisfy the compiler:

function commentHasAuthor(
  comment: CommentModel,
): comment is Required<CommentModel> {
  return comment.author != null;
}
 
function extractAuthors(comments: CommentModel[]): UserModel[] {
  return comments.filter(commentHasAuthor).map(comment => comment.author);
}

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'):

const user = { email: 'john.doe@example.com' } as UserModel;
// ...
const initial = user.firstName.toUpperCase()[0];

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'):

function getCommentersEmails(comments: CommentModel[]): string[] {
  return comments.map(comment => comment.author!.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.

function getCommentRepliesSummary(
  id: string,
  repliesMap: Record<string, CommentModel[]>,
): string {
  const replies = repliesMap[id];
  const latestTimestamp = replies[replies.length - 1].timestamp;
  return `Has ${replies.length} replies (most recent at ${formatDate(latestTimestamp)}).`;
}

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:

function getCommentRepliesSummary(
  id: string,
  repliesMap: Record<string, CommentModel[]>,
): string {
  const replies = repliesMap[id] ?? [];
  const latestTimestamp = replies[replies.length - 1]?.timestamp;
  if (latestTimestamp == null) {
    return 'Has no replies';
  }
  return `Has ${replies.length} replies (most recent at ${formatDate(latestTimestamp)}).`;
}

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 anys 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

  1. At time of writing, latest TypeScript version is 5.6.