Tue 24 March 2020

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] .

will return rather than . If the nums array is empty, the function will return [undefined, undefined] . This sort of object with several undefined s 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);





}

}

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;





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;



or a single check:

const range = extent([ 0 , 1 , 2 ]);

if (range) {

const [min, max] = range;

const span = max - min;

}



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: