TypeScript nullable types

July 06, 2016

The development branch of TypeScript (which will be TypeScript 2.0) added support for nullable types — letting you express, in the type system, whether a type is possibly null or not. I think the implementation is neat and unlike what I've seen in other languages so I thought I'd write a post about it.

For background, recall TypeScript's design space: it is modelling the way existing JavaScript code works. (This constraint really helps prevent them from going off into architecture astronaut land for new type system features!) If you look a browser JavaScript API such as JSON.stringify you'll see that, for example, if you pass a number as the third argument ("space") it does one thing and if you pass a string it does a different thing. Many APIs work in this way.

TypeScript models this behavior with union types, which at first look like the sum types you see in ML/Haskell/Rust but are actually quite different. Think of types like sets of allowed values: the type "number" is the set {1, 2, 3, ...}, and a function that accepts a number accepts any value in that set. Or a more complex example: the structural type "{foo: number}" is the set of all objects that have a foo property of type number (including {foo: 1} or {foo: 3, bar: 4}).

A union type then is just a union of sets: string|number is a set that includes 1 and also "hello". Using that for the space parameter to JSON.stringify correctly models you may pass it a number or a string but nothing else.

What can you do if you have a value with a union type? Here's where it's easy to get confused. If you have x: string|number, what methods are available on x? The set of operations you can do with a type are the things that all values in the type implement, which ends up being the intersection of the operations. Both string and number have a .toString() method, so you can safely call .toString() on x regardless of its value, but there isn't much else. (To see for yourself, hit ctl-space after the final period on this example.)

In practice, usually what you do with a union type is a type test to see which sort of value you actually have; presumably JSON.stringify does something like

if (typeof space === 'string') {
  ...string-specific behavior...
}

(This check is morally similar to the pattern-match you might do in an ML-derived language, but in those languages you match on the enum value, not the type. One way to see this difference is to see that Haskell's Either has Left String | Right String, while in TypeScript the type string|string is equivalent to just string.)

Finally, here's where nullability fits in. In previous TypeScripts, any variable can also have the value null. (And there's also undefined, which shows up in uninitialized variables and a few other situations. Why is it that both null and undefined exist? Because JavaScript.) From the "types are sets" perspective, it's easy to see this means every type implicitly includes null and undefined in its allowed set of values.

So to add nullability checking to TypeScript, all they needed to do is remove those implied members of the sets. With strict null checking on, when you write string the type admits strings only. The language then declares a new type named null that is the set of just one value: null. To represent a possibly-null value, it's the union type string|null.

Almost all remanining behavior then falls out of the existing union type semantics. For example, what can you do with a string|null? Well, there are no methods on null, so there's no methods on the union either. You probably want to do a test like we did earlier in the JSON.stringify example, but in this case, the test is a null check — and the compiler is helping you find where a null check is necessary!

In practice, the bulk of the nullability work in the TypeScript compiler seems to have been on improving the flow-sensitive type analysis. Consider code like:

let x: string|number = ...;
if (typeof x === 'string') {
  // x has type 'string' here.
} else {
  // by elimination, x must have type 'number' here.
  x *= 4;  // legal on numbers.
}

The compiler must do the "by elimination" reasoning, so that it doesn't flag that else arm as an error. It already had logic for this before they added null (for cases like this string|number union type) but with nullability they found more places to improve it.