TypeScript: Null and Undefined Types
Trying to perform operations on undefined
or null
values is one of the most common causes of runtime errors in JavaScript code.
Sometimes the variables in our programs intentionally “hold” undefined
or null
- JavaScript is a very flexible language, and we can have legitimate reasons to want to use these values to express things in our code. Other times, we may inadvertently be interacting with the values undefined
and null
because we did not account for all the possible code paths, or we did not understand a third party or platform API well enough.
How can we protect ourselves, and therefore also our users, from undefined
and null
values appearing where we don’t want them?
TypeScript to the rescue!
Let’s take a look at an example of a very common operation within a web app: looking something up in the DOM and updating it in some way.
const myComponent = document.getElementById('component');
myComponent.innerHTML = 'updated content';
This code seems harmless enough, and running this through the TypeScript compiler with no extra configuration will not yield any errors.
However, if we think a lot more carefully about the moving parts here, the key thing is that we understand the interface
that is representative of this part of the DOM API.
We know intuitively that when things are looked up in the DOM, there is no guarantee that they will be available to be found. What does getElementById()
return in that case? It turns out it is the value null
.
This means that if, in our code above, the element with an ID of 'component'
doesn’t exist at the moment we try and look it up, the myComponent
variable will be null
and our program will crash when we effectively try and access null.innerHTML
on the next line.
Uncaught TypeError: Cannot read property ‘innerHTML’ of null
So why on earth didn’t TypeScript warn us about this possibility?
Let’s take a look at that “interface” (specifically the signature of the function) we spoke about for getElementById()
. If we hover over the method in a modern IDE which supports TypeScript, such as VSCode, we will see something similar to the following:
(method) Document.getElementById(elementId: string): HTMLElement
It looks like we have found our disconnect - at runtime there are two possible values (an HTMLElement
object or null
), but at compile time TypeScript believes the return value will always be of one single type, HTMLElement
.
The reason for this is that, prior to version 2 of TypeScript, null
and undefined
were actually what’s called a “subtype” of every other type. This means that null
was assignable to any other type including number
s, string
s etc.
As of TypeScript 2 however, we have the concept of “non-nullable types”. This is a fancy way of saying that null
has become its own unique type. It’s no longer a subtype of all the other types which means that if a variable/parameter/return type of a function could be a string
or null
you will have to type it as such with a union type of both of them: string | null
.
The exact same thing is true for undefined
under this “non-nullable” types umbrella: undefined
now has its own distinct type, which is not assignable to anything else. It is also worth explicitly noting that undefined
and null
types are therefore also not assignable to each other.
So let’s take another look at our example with getElementById()
to see what we mean by all that. Here is the simple example again:
const myComponent = document.getElementById('component');
myComponent.innerHTML = 'updated content';
This new behavior of non-nullable types is actually opt-in TypeScript version 2 and above, so we need to go into our tsconfig.json file to enable it.
All we have to do to enable this behavior is to set “strictNullChecks”: true
(or rely on it being set via the umbrella “strict”: true
flag) in our tsconfig.json compilerOptions
. If we were to rerun our simple program through the TypeScript compiler again we would now get an error.
[ts] Object is possibly ‘null’
Hurray! That error makes perfect sense based on our understanding of what getElementById()
can return.
If we hover over the method in the same way we did before we’ll see something really interesting: the function signature has actually been updated!
(method) Document.getElementById(elementId: string): HTMLElement | null
The return type is now a union type of HTMLElement
or null
.
In our code, we’ve so far implicitly just assumed that the result of getElementById()
will be an HTMLElement
which we can then use and access the innerHTML
property of. However, TypeScript is now picking up on the fact that there is no guarantee that that will possible. There’s no way to know by statically analyzing this program that the element we are looking up will exist in the DOM at the time that we’re querying for it.
One of the great features of TypeScript which partners really well with these non-nullable types is the fact that it will do what’s called “control flow analysis” on our programs when it performs type checking. In simple terms this means that it will look at our if
statements, our switch
cases and similar kinds of constructs within our programs and know how that affects the types within our programs. Let’s see what we mean using our example.
Essentially if we put in an if
statement to check that myComponent
is truthy, we will see that the TypeScript error goes away. At the point at which we run our if
statement, myComponent
could be an HTMLElement
or null
. But within the body of our if
statement, we know that myComponent
must be truthy, and with null
being a falsy value, the type of null
can be removed from our union type:
const myComponent = document.getElementById('component');
// At this point `myComponent` is of type HTMLElement | null
if (myComponent) {
// Within the block of this statement, `myComponent` must contain a truthy value,
// so the `null` type can be removed from the union type.
//
// This means that within this block, `myComponent` is of type `HTMLElement` and TypeScript
// is happy for us to access its `innerHTML` property
myComponent.innerHTML = 'updated content';
}
This is control flow analysis in action and it’s so useful when combined with non-nullable types.
Even though non-nullable types/“strictNullChecks” is an opt-in feature in TypeScript 2 and above, I would highly recommend that you do start all of your TypeScript applications with it enabled. You will catch more potential issues, and write higher quality code.
The TypeScript Team at Microsoft strongly encourages all users to enable strict null checks in their code, and as a result they made it enabled by default within tsconfig.json files which are generated via their helper tool:
tsc --init
Running that to start a new project is a great way to ensure that you have a solid foundation of the recommended compilerOptions
for your code.
To summarize then, we’ve looked at the theory behind having null
and undefined
be their own distinct types in TypeScript 2 and above, setting “strictNullChecks”: true
in our tsconfig.json which enables these non-nullable types, and how control flow analysis can utilise our if
statements and similar such constructs to inform how the types flow through our programs.
We’ve also seen how the default type information for functions built into the DOM and other platform APIs get much stricter when we enable strict null checks, which ultimately gives us the best chance of shipping high quality code to our users.
Onwards!