DEV Community

loading...

TypeScript "strictNullChecks" - a migration guide

Talin
I'm not a mad scientist, I'm a mad natural philosopher.
・7 min read

So you want to write high quality code, right? After all, that's presumably why you chose TypeScript in the first place.

TypeScript has a number of compiler options, settable in tsconfig.json, which determine how strict or lenient the compiler is. One of the most important options is strictNullChecks.

Effect of setting strictNullChecks

The default setting is strictNullChecks=false. With strict null checks disabled, every value, every parameter and every object property is both nullable and optional. If you try to assign a value of null or undefined to that variable, the compiler won't complain.

let a: int = 1;
a = null; // OK
Enter fullscreen mode Exit fullscreen mode

With strictNullChecks=true this is no longer allowed - attempting to assign null to a non-nullable field will produce a compiler error.

let a: int = 1;
a = null; // Error!
Enter fullscreen mode Exit fullscreen mode

That seems pretty straightforward, right? Well, not so fast. You see, the compiler will also generate an error if you try to assign a value that might be null to a non-nullable variable:

let a: int = 1;
let b?: int; // Value is not yet set, so undefined
a = b; // Error!
Enter fullscreen mode Exit fullscreen mode

In addition, the compiler will no longer allow you to leave a non-optional variable uninitialized:

let a: int; // Error, a must be initialized!
Enter fullscreen mode Exit fullscreen mode

Benefits of strictNullChecks

Why is strictNullChecks important? There are a number of reasons, I will name just two:

  • strictNullChecks can, and often will, locate actual bugs in your code. Lots of bugs, in fact. In other words, this is not just strictness for the sake of strictness; enabling this option can be a valuable complement to writing unit tests.
  • With this option enabled, autocompletion and type checking in Visual Studio Code and other editors becomes considerably smarter.

That last point deserves some explanation. Enabling this option effectively narrows the set of possible types for every variable, which means that the editor can infer more about the variable's type than it would otherwise.

Migrating existing code bases

If you are starting a new TypeScript project, enabling strictNullChecks is pretty easy - and highly recommended.

However, what if you have a large code base, that was not written with strictNullChecks in mind? Enabling the strictNullChecks option is going to produce lots and lots of errors, all of which will need to be fixed.

I recently migrated a project with roughly 20K lines of source; I ended up having to fix over 600 type errors as a result.

Worse, there's no one-size-fits-all fix for these errors. Although the fixes are usually pretty simple, it can't be done with a search and replace. You have to look at each and every error and think about what is the correct thing.

Nor is there a way to conditionally enable strictNullChecks for some source files and not others. It's either all or nothing for your whole project.

Fortunately, there are some methods you can use to soften the blow, which I will outline below.

Migrating Incrementally

Unless you plan on sitting down and fixing every error in a single sitting, you're going to want a way to be able to fix a subset of the errors and then check in you work - and you won't want to break your build in doing so.

The way around this is pretty simple:

  1. Set strictNullChecks to true.
  2. Do a test compile. You'll see lots of errors.
  3. Fix some of the errors; repeat until you get tired. (Pro tip: don't code when you're tired, you're more likely to make mistakes.)
  4. Set strictNullChecks back to false.
  5. Check in the code.
  6. Once you've recovered, go back to step 1.

This works because any code that is correct with strictNullChecks enabled is also acceptable with it disabled; in other words, turning it off will never generate any additional errors. So even if you can't do everything in one go, you can make partial progress towards your goal. Eventually once all the errors have been fixed, you can leave the option enabled permanently.

Techniques for handling null & undefined values.

OK, so this sounds like a lot of work, right? Well the good news is that fixing null/undefined errors is generally pretty easy. Those 600 errors I mentioned? Took less than a day to get all of them.

The following sections provide some suggestions on how to handle null/undefined values. Note that goal here is not simply to make the compiler shut up - the goal of each of these techniques is to make the code more correct.

Explicit test for truthiness

TypeScript’s type inference can narrow the type of an expression inside an if-block:

const userProfile: UserProfile | null = getProfile(id);

if (userProfile) {
  // The compiler knows that the type of 'userProfile' in
  // this block is non-null, so this dereference won't be
  // flagged as an error
  console.log(userProfile.createdAt);
}
Enter fullscreen mode Exit fullscreen mode

Optional chaining operator

Another way to test a value for null or undefined is to use
the optional chaining operator, ?.:

const userProfile: UserProfile | null = getProfile(id);
console.log(userProfile?.createdAt);
Enter fullscreen mode Exit fullscreen mode

In this case, if userProfile is null, then it will return null from the expression instead of attempting to dereference the value.

Explicitly telling the compiler a value is non-null

An exclamation point (!) at the end of an expression tells the compiler “Hey, I know this is non-null and non-undefined, you don’t need to warn me about it.”

const userProfile: UserProfile | null = getProfile(id);
console.log(userProfile!.createdAt);
Enter fullscreen mode Exit fullscreen mode

Since this is effectively disabling the null/undefined check, it should be used with caution - but there are rare cases where this is the clearest and most correct approach.

One example is where a class has a property which is not initialized in the constructor, but is initialized immediately afterwards, possibly as a result of an ‘init()’ method. Since the value is not set in the constructor it has to be marked as optional - but the programmer knows that it’s never going to be undefined afterwards, so forcing them to add an extra null check every time the property is accessed creates clutter.

Throw an exception if a value is non-null

This is a variant of the if-block technique which inverts the logic:

const userProfile: UserProfile | null = getProfile(id);
if (!userProfile) {
  throw new Error('Profile should not be null/undefined!');
}
// Type is narrowed from this point on.
console.log(userProfile.createdAt);
Enter fullscreen mode Exit fullscreen mode

Note that if the value was null, the compiler would have thrown an error anyway; the advantage here is that the exception messages explains exactly what the problem was instead of requiring the programmer to figure it out.

Throw an exception II - assertDefined helper

If an if-statement with an exception is too verbose for your taste, you can define a helper function to do it for you:

const userProfile: UserProfile | null = getProfile(id);
assertDefined(userProfile, 'userProfile in function X');
// Compiler knows that userProfile is non-null/undefined.
console.log(userProfile.createdAt);
Enter fullscreen mode Exit fullscreen mode

How does the compiler know that the value of userProfile is defined after the call to assertDefined? The magic is in the return type of the helper function, which uses the TypeScript asserts feature:

/** Helper function which throws an exception if the value
    is either null or undefined. This allows TypeScript to
    correctly infer the non-nullable type.
*/
export function assertDefined<T>(value: T, name?: string): asserts value {
  if (value === null) {
    throw new Error(`${name ?? 'Value'} should not be null.`);
  }
  if (value === undefined) {
    throw new Error(`${name ?? 'Value'} should not be undefined.`);
  }
}
Enter fullscreen mode Exit fullscreen mode

The second argument is optional, but recommended - it can help you track down where in the code the null value was. The error message will be something like “userProfile in function X should not be null.”.

Use getter methods to tighten the type of a property

If the variable in question is a class property, you can declare a getter method which does the null/undefined check and tightens the type.

class SomeClass {
  private name: string | null;

  public get profileName(): string {
    if (!this.name) {
      throw Error('name should not be null/undefined');
    }
    return this.name;
  }
}
Enter fullscreen mode Exit fullscreen mode

Here the private name field is nullable, but the public profileName property is not.

One downside of this is that you need to come up with two different names for the same field. If you don’t like that, one option is to use the new “private member” syntax:

class SomeClass {
  #name: string | null;

  public get name(): string {
    if (!this.#name) {
      throw Error('name should not be null/undefined');
    }
    return this.#name;
  }
}
Enter fullscreen mode Exit fullscreen mode

Note that the #name syntax is a JavaScript feature, not TypeScript - it works in TypeScript, but the semantics are slightly different than private - check your language reference for details. Also note that you have to use one or the other - you can’t have private on a #private field.

Use a placeholder object

In many cases, if you have a null or undefined value, you can substitute a valid but empty or "blank" object to be used instead. This is especially good for arrays:

(getUserNames() ?? []).find(user => user.name === name);
Enter fullscreen mode Exit fullscreen mode

This is also a great place to use the TypeScript null-coalescing operator (??). Generally you'll only want to use the placeholder if the object is actually null or undefined, not when it's falsey for some other reason.

Push the burden of null/undefined checks onto consuming components

Instead of checking for null right away, modify methods/components to accept null parameters:

const Avatar: FC<{ userId: string | null }> = 
  ({ userId }) => userId 
    ? <div>{getUser(userId)}</div> 
    : null;

const userId: string | null;
return <Avatar userId={userId} /> // Null is OK here since Avatar handles it.
Enter fullscreen mode Exit fullscreen mode

Undefineds and closures

One limitation of the ‘if-block’ technique is that it may not apply if the variable is being used inside a nested function definition:

if (this.userProfile) {
  runInAction(() => {
    // Error! user profile might have changed before this function runs,
    // which means it could be null.
    console.log(this.userProfile.name);
  });
}
Enter fullscreen mode Exit fullscreen mode

You can get around this by saving the value in a local variable:

const lp = this.userProfile;
if (lp) {
  runInAction(() => {
    // OK - lp cannot change because it's a constant.
    console.log(lp.name);
  });
}
Enter fullscreen mode Exit fullscreen mode

Redefine the type containing optional fields

What if you are dealing with a data structure that defined some fields as optional, only you know that those fields are in fact required? This can happen if, for example, the data structure is being generated by some automated process such as a database object mapper. The mapper is being conservative and defining fields as optional, but you know better - and it's a pain to have to check each and every property access for undefined when you know that the data can never be null.

One way to handle this is to redefine the type! There’s a TypeScript function that you can use to make some keys required:

/** TypeScript type modifier thats selectively makes some fields required */
export type RequiredKeys<T, K extends keyof T> = Exclude<T, K> & Required<Pick<T, K>>;
Enter fullscreen mode Exit fullscreen mode

So for example, if we wanted to make UserProfile have required fields:

/** Make optional fields non-optional. */
export type MyUserProfile = RequiredKeys<
  UserProfile,
  'email' | 'persona' | 'valid'
>;
Enter fullscreen mode Exit fullscreen mode

Conclusion

While it may seem like a lot of work to migrate a code base to use strict null checks, it's worth it. The result is higher-quality code that is more robust and resilient against bugs.

Discussion (0)