Item 31: Push Null Values to the Perimeter of Your Types

Chapter 4 of Effective TypeScript covers type design: the process of crafting your types to accurately model your domain. Design your types well and you'll have a happy, productive relationship with the type checker. Design them poorly and you'll find yourself constantly fighting unproductive battles with it. This item discusses a frequent source of problems in type design: where to put your null types.

When you first turn on strictNullChecks, it may seem as though you have to add scores of if statements checking for null and undefined values throughout your code. This is often because the relationships between null and non-null values are implicit: when variable A is non-null, you know that variable B is also non-null and vice versa. These implicit relationships are confusing both for human readers of your code and for the type checker.

Values are easier to work with when they're either completely null or completely non-null, rather than a mix. You can model this by pushing the null values out to the perimeter of your structures.

Suppose you want to calculate the min and max of a list of numbers. We'll call this the "extent." Here's an attempt:

function extent(nums: number[]) {
let min, max;
for (const num of nums) {
if (!min) {
min = num;
max = num;
} else {
min = Math.min(min, num);
max = Math.max(max, num);
}
}
return [min, max];
}

The code type checks (without strictNullChecks) and has an inferred return type of number[], which seems fine. But it has a bug and a design flaw:

  • If the min or max is zero, it may get overridden. For example, extent([0, 1, 2]) will return [1, 2] rather than [0, 2].
  • If the nums array is empty, the function will return [undefined, undefined]. This sort of object with several undefineds will be difficult for clients to work with and is exactly the sort of type that this item discourages. We know from reading the source code that min and max will either both be undefined or neither, but that information isn't represented in the type system.

Turning on strictNullChecks makes both of these issues more apparent:

function extent(nums: number[]) {
let min, max;
for (const num of nums) {
if (!min) {
min = num;
max = num;
} else {
min = Math.min(min, num);
max = Math.max(max, num);
// ~~~ Argument of type 'number | undefined' is not
// assignable to parameter of type 'number'
}
}
return [min, max];
}

The return type of extent is now inferred as (number | undefined)[], which makes the design flaw more apparent. This is likely to manifest as a type error wherever you call extent:

const [min, max] = extent([0, 1, 2]);
const span = max - min;
// ~~~ ~~~ Object is possibly 'undefined'

The error in the implementation of extent comes about because you've excluded undefined as a value for min but not max. The two are initialized together, but this information isn't present in the type system. You could make it go away by adding a check for max, too, but this would be doubling down on the bug.

A better solution is to put the min and max in the same object and make this object either fully null or fully non-null:

function extent(nums: number[]) {
let result: [number, number] | null = null;
for (const num of nums) {
if (!result) {
result = [num, num];
} else {
result = [Math.min(num, result[0]), Math.max(num, result[1])];
}
}
return result;
}

The return type is now [number, number] | null, which is easier for clients to work with. The min and max can be retrieved with either a non-null assertion:

const [min, max] = extent([0, 1, 2])!;
const span = max - min; // OK

or a single check:

const range = extent([0, 1, 2]);
if (range) {
const [min, max] = range;
const span = max - min; // OK
}

By using a single object to track the extent, we've improved our design, helped TypeScript understand the relationship between null values, and fixed the bug: the if (!result) check is now problem free.

A mix of null and non-null values can also lead to problems in classes. For instance, suppose you have a class that represents both a user and their posts on a forum:

class UserPosts {
user: UserInfo | null;
posts: Post[] | null;

constructor() {
this.user = null;
this.posts = null;
}

async init(userId: string) {
return Promise.all([
async () => this.user = await fetchUser(userId),
async () => this.posts = await fetchPostsForUser(userId)
]);
}

getUserName() {
// ...?
}
}

While the two network requests are loading, the user and posts properties will be null. At any time, they might both be null, one might be null, or they might both be non-null. There are four possibilities. This complexity will seep into every method on the class. This design is almost certain to lead to confusion, a proliferation of null checks, and bugs.

A better design would wait until all the data used by the class is available:

class UserPosts {
user: UserInfo;
posts: Post[];

constructor(user: UserInfo, posts: Post[]) {
this.user = user;
this.posts = posts;
}

static async init(userId: string): Promise<UserPosts> {
const [user, posts] = await Promise.all([
fetchUser(userId),
fetchPostsForUser(userId)
]);
return new UserPosts(user, posts);
}

getUserName() {
return this.user.name;
}
}

Now the UserPosts class is fully non-null, and it's easy to write correct methods on it. Of course, if you need to perform operations while data is partially loaded, then you'll need to deal with the multiplicity of null and non-null states.

(Don't be tempted to replace nullable properties with Promises. This tends to lead to even more confusing code and forces all your methods to be async. Promises clarify the code that loads data but tend to have the opposite effect on the class that uses that data.)

Things to Remember

  • Avoid designs in which one value being null or not null is implicitly related to another value being null or not null.
  • Push null values to the perimeter of your API by making larger objects either null or fully non-null. This will make code clearer both for human readers and for the type checker.
  • Consider creating a fully non-null class and constructing it when all values are available.
  • While strictNullChecks may flag many issues in your code, it's indispensable for surfacing the behavior of functions with respect to null values.
Like this post? Consider subscribing to my newsletter, the RSS feed, or following me on Twitter.
comments powered by Disqus
Effective TypeScript Book Cover

Effective TypeScript shows you not just how to use TypeScript but how to use it well. The book's 62 items help you build mental models of how TypeScript and its ecosystem work, make you aware of pitfalls and traps to avoid, and guide you toward using TypeScript’s many capabilities in the most effective ways possible. Regardless of your level of TypeScript experience, you can learn something from this book.

After reading Effective TypeScript, your relationship with the type system will be the most productive it's ever been! Learn more »