TypeScript nullable types
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.