Following up on the previous gist about avoiding non-null-assertion operator I think it would be good to see some examples of null checking in TypeScript.
Assumming we're using TypeScript with --strictNullChecks
, and that this is what a banana looks like:
type Banana = {
id: number,
open: () => void
};
We have a list of bananas and we're trying to find bananas by id.
const bananas: Array<Banana> = [];
...
const banana = bananas.find(banana => banana.id === id);
Our React + Typescript project has a get function like this:
function getBananaById(id: number): Banana {
return bananas.find(banana => banana.id === id)!; // ! bang!
}
function openBananaById(id: number) {
const banana = getBananaById(id);
banana.open();
}
We use the non-null assertion operator (the exclamation mark a.k.a bang ) here because we assumed that the id provided to both functions would always be valid. After all, the user can only select a banana from the list, as opposed to the id being calculated or read from direct user input.
Under this assumption, this code would never throw, because the find
function will always find a banana and return it.
"One day a banana will not be found"
Depending of what we do, the program will throw or not when a banana is not found. The program will throw if we do any of the following:
Just cheating the type checker with the non-null assertion operator.
const bananas: Array<Banana> = [];
function getBananaById(id: number): Banana {
return bananas.find(banana => banana.id === id)!; // !
}
function openBananaById(id: number) {
const banana = getBananaById(id);
banana.open(); // may throw an error here
}
Result: The program may throw Uncaught TypeError: Cannot read property 'open' of undefined
when we "consume" the banana instance. This can happen anywhere we access a property of banana
.
(try it on codesandbox)
Just cheating the type checler with the non-null assertion operator, but in a different place.
const bananas: Array<Banana> = [];
function getBananaById(id: number): Banana | undefined {
return bananas.find(banana => banana.id === id);
}
function openBananaById(id: number) {
const banana = getBananaById(id);
banana!.open(); // may still throw an error here
}
Result: The program may throw Uncaught TypeError: Cannot read property 'open' of undefined
when we "consume" the banana instance. This would tipically happen in multiple places.
(try it on codesandbox)
const bananas: Array<Banana> = [];
function getBananaById(id: number): Banana {
const banana = bananas.find(banana => banana.id === id);
if (!banana) {
throw new Error(`Error. Could not find banana with id: ${id}.`);
}
return banana;
}
function openBananaById(id: number) {
const banana = getBananaById(id);
banana.open(); // will never throw
}
Result: The program may throw a custom error "by design" when calling getBananaById
.
(try it on codesandbox)
If we don't really need our function to throw when a banana is missing, we have two options:
const bananas: Array<Banana> = [];
function getBananaById(id: number): Banana | undefined {
return bananas.find(banana => banana.id === id);
}
function openBananaById(id: number) {
const banana = getBananaById(id);
if (banana)
banana.open();
}
}
Result: The program will not throw. But we need to perform the same null check over and over.
(try it on codesandbox)
"Instead of using a null reference to convey absence of an object (for instance, a non-existent customer), one uses an object which implements the expected interface, but whose method body is empty. The advantage of this approach over a working default implementation is that a null object is very predictable and has no side effects: it does nothing." (source: Wikipedia)
In our example it would look like this:
const bananas: Array<Banana> = [];
const NullBanana: Banana = { id: 0, open: () => {} };
function getBananaById(id: number): Banana {
return bananas.find(banana => banana.id === id) || NullBanana;
}
function openBananaById(id: number) {
const banana = getBananaById(id);
banana.open(); // will never throw
}
Result: The program will not throw. And we don't need to perform null checks before using the instance.
(try it on codesandbox)
In the above code we declare and initialise a NullBanana
constant, and then the getBananaById
function returns either the result of the find
function call or NullBanana
.
When not finding a banana is unexpected, we prefer to perform null-check and throw at the "source":
function getBananaById(id: number): Banana {
const banana = bananas.find(banana => banana.id === id);
if (!banana) {
throw new Error(`Error. Could not find banana with id: ${id}.`);
}
return banana;
}
When not finding a banana is a valid case, we prefer to use null object pattern:
const NullBanana: Banana = { id: 0, open: () => {} };
function getBananaById(id: number): Banana {
return bananas.find(banana => banana.id === id) || NullBanana;
}
Can you think of other ways of handling null/undefined?