How to handle deeply-nested nullable fields in JavaScript and TypeScript
Nullability is a hard problem. This is especially true for JavaScript/TypeScript. Front-ends and Web Servers (which makes up most of JavaScript usage) tend to work with a lot of JSON. And although it's relatively easy to do JSON.parse(myJSONString)
, it's a whole different game when you want to safely traverse the resulting JavaScript object.
And since TypeScript offers type-safely to those who traverse these objects properly, we have to be mindful of preserving the type guarantees and not circumvent the type system needlessly.
Let's start without deeply nested nullable interface:
interface IUser {
id: string;
name: string;
addresses?: Array<{
street: string;
suburb: string;
postcode: string;
country: string;
mail?: Array<{
subject: string;
sentOn: string;
body: string;
}>;
}>;
}
Since the address could be null:
const user: IUser = { id: "1", name: "foo" };
const firstAddress = user.addresses && user.addresses[0]; // throws an error in javascript, TypeError in TypeScript.
Option 0: A naive approach to traversing
If you use &&
to “chain” your accessors, You can get around this problem a little bit:
const firstAddressStreet =
user.addresses && user.addresses[0] && user.addresses[0].street;
We can quickly see how this approach makes things very hard to “grok” in your head. If you need to extend this beyond a couple of levels, you end up with:
const firstMailSentOn =
user.addresses &&
user.addresses[0] &&
user.addresses[0].mail &&
user.addresses[0].mail[0] &&
user.addresses[0].mail[0].sentOn;
Option 1: Enter idx
I have found idx to be a safe method of accessing these nested nullable values. A naive implementation of this would be:
const idx = <Input, Output>(input: Input, select: (input: Input) => Output) => {
try {
return select(input);
} catch (e) {
return null;
}
};
Having this, you will be able select the previous property like this, in JavaScript:
const firstMailSentOn = idx(user, u => user.addresses[0].mail[0].sentOn);
This works because any operation which throws is rescued by our catch block which returns null.
But this won't work in TypeScript quite the same. No, TypeScript will complain that you are trying to select addresses
, but addresses
field is possibly null. Therefore, when using idx with TypeScript, you tend to use the non-null assertion (!
) a lot.
const firstMailSentOn1 = idx(user, u => user.addresses[0].mail[0].sentOn); // TypeError:
const firstMailSentOn2 = idx(user, u => user.addresses![0]!.mail![0]!.sentOn); // silences type errors
As good of an idea as idx seems to be, it has a couple of downsides.
A non-null assertion in TypeScript should be discouraged: Non-null assertion means – “I know better, compiler. Stop complaining”. Once you add this precedence to a codebase, you're giving up some guarantees that TypeScript gives you about your apps' behaviour. It's better to not introduce a bad behaviour even in a contained space like idx, to ensure long-term maintainability of code. See broken window theory.
Idx can be abused: Once you introduce idx, you'll let your developers use it as a short-hand for anything which needs a try-catch surrounding it. In large codebases, this is all too common as policing code becomes harder.
const thisIsWrong = idx(obj, p =>
myVeryUnsafeOperationWithSideEffects(p, otherVariable)
); // js-version with babel plugin will complain here though.
Option 2: Maybe
: a monadic adventure
Without going into a lengthy discussion about what Functors, Applicatives and Monads are, let me make a proclamation that monads are great for the use case of traversing deeply nested null values because they let you “map over nullable values in small isolated contexts”. Let's dive into an example:
Maybe/Option types are prevalent in many functional programming languages and there are a number of JavaScript/TypeScript libraries that implement them. All of them have the same characteristics. For this example, we'll consider the excellent true-myth Maybe
type.
import { Maybe } from "true-myth";
const firstMailSentOn = Maybe.fromNullable(user.addresses)
.map(addresses => addresses[0])
.map(firstAddress => firstAddress.mail)
.map(allMail => allMail[0])
.map(firstSentMail => firstSentMail.sentOn)
.unwrapOr("never");
Maybe allows us to “box” a potentially nullable value, and map
over it with a mapping function. The mapping function may return another nullable value. But the subsequent mapping function will only be hit if the previous mapping function resolves with a non-null value. In the previous example, if the .map(firstAddress => firstAddress.mail)
returns null because firstAddress.mail
is null, .map(allMail => allMail[0])
will not be invoked. It will instead short-circuit to .unwrapOr("never")
, making firstMailSentOn === "never"
.
There are a few advantages of this approach, especially for TypeScript.
- We don't have to circumvent the type system in any way. We don't have to do non-null assertions since the nullable part of the type gets thrown out at each
map
. The reason for this is eachmap
only invoked if the previous step returns a non-null value. - If your types are correct, this will be extremely safe. (Doesn't
throw
) - You can elegantly supply a default value (
null
in our case – viaunwrapOr
), thereby forcing the developer to consider the alternative scenario – when a nullable value is encountered.
One might look at this and question the readability of this approach. It's subjective – but I'm sympathetic to anyone who says a selection that looks like a.b.c.d
looks much simpler than Maybe.fromNullable(a).map(p => p.b)...
.
Option 3: Optional chaining
This is one of those things I'm most excited for in JavaScript/TypeScript. At the time of writing this article, this is still very much a TC39 proposal. Optional chaining tries to solve this problem by introducing a new syntax:
const firstMailSentOn = user.addresses?.[0]?.mail?[0]?.sentOn;
If you're familiar with Ruby, this will look a lot like the safe navigation operator (foo&.bar
).
Essentially it safely navigates the chain, stopping whenever it sees a null/undefined value so as not to throw and crash the program. Although I can appreciate the monadic functional method of traversing nested values, the signal-to-noise ratio of this new syntax is relatively higher. In that vein, I'm very excited about its arrival in TypeScript 3.7.
Can I use optional chaining today in TypeScript?
Short answer – no. It's landing in TypeScript 3.7, and there are no experimental...
flags to support it as of yet.
However, if you must, you can use ts-optchain to do something similar. There will be compiler transformation you'll have to configure. And in the future, if you do end up using official optional chaining syntax, you'll have to do a code migration as well.