Optional Chaining in TypeScript

Traversing tree-like structures safely at runtime

Neville Bowers
Inside Rimeto
8 min readAug 20, 2018

--

Photo by Chris Becker on Unsplash

Update November 2019: TypeScript 3.7 now supports optional chaining as a first-class feature!

Traversing Sketchy Tree-like Structures

When processing data, we frequently traverse tree-like structures to extract specific data elements of interest. For example, we might need to grab the city name of the primary office from a user record retrieved from a 3rd-party API:

const apiResult = {
name: "Neville Bowers",
office: {
primary: {
city: "San Francisco",
state: "CA"
}
}
}

To extract the primary office city name we might start with the following code:

const city = apiResult.office.primary.city;
// --> "San Francisco"

Straightforward enough. However, this code makes the dangerous assumption that the API response will always take the same shape at runtime. Indeed, we will encounter trouble the first time our API returns a user profile missing the office sub-structure, for example:

const apiResult = {
name: "Yatharth Agarwal"
}
const city = apiResult.office.primary.city;
// --> Uncaught TypeError: Cannot read property 'primary' of null

Ouch. Moreover, if we have not been diligent about catching exceptions, our app may crash altogether! Clearly we need to be much more careful in our traversal of apiResult — an obvious realization in hindsight but very easy to overlook at development time, especially if our API returns a consistent structure most of the time, or if the corresponding API documentation omits full disclosure of all possible response states.

The defensive approach is for us to assume that all properties in these tree-like structures are optionals. Let's explore several approaches for dealing with this now recognized uncertainty.

a/ Nested Logic Statements

We can use nested logic statements to extract city safely:

let city: string | undefined = undefined;
if (apiResult) {
const office = apiResult.office;
if (office) {
const primary = office.primary;
if (primary) {
city = primary.city;
}
}
}

While this solution works and is resilient to unexpected runtime changes to apiResult, it is verbose and relatively difficult to read. Some developers would even call this code a modest “pyramid of doom.” Moreover, this approach requires that we now use a reassignable type for city (e.g., using let vs const) — a modest regression in code strictness.

b/ Nested Ternary Expressions

We can use nested ternary expressions to extract city safely:

const city = !apiResult
? undefined
: !apiResult.office
? undefined
: !apiResult.office.primary
? undefined
: apiResult.office.primary.city;

While this approach is slightly more concise than nested logic statements and allows us to restore immutability to city, the code is still difficult to follow and has redundancies (e.g., undefined appears 3 times).

c/ Logic Expressions

We can use logic expressions to extract city safely:

const city =
apiResult &&
apiResult.office &&
apiResult.office.primary &&
apiResult.office.primary.city;

This implementation is more concise than nested ternary expressions; nonetheless, it still requires much redundant code to implement.

d/ Try/Catch Blocks

We can use try/catch statements to extract city safely:

let city: string | undefined = undefined;
try {
city = apiResult.office.primary.city;
} catch (error) {
// Swallow Error
}

The try/catch approach brings us the closest to the simplicity of our original naïve solution. But a lot of boilerplate code is required for each traversal and we again must drop const from city to scope and assign it correctly.

e/ lodash, ramda, etc.

Several libraries exist to facilitate safe tree traversal. With lodash, for example, we can use the get utility to extract city safely:

import * as _ from 'lodash';const city = _.get(apiResult, 'office.primary.city', undefined);

This solution is the most concise of any we have looked at and is only slightly longer than our original naïve implementation. However, we give up several important development-time benefits, including:

  • IDE code-completion when writing the traversal path.
  • Compiler verification of the path office.primary.city (a small typo in the path string could be difficult to track down later).

The Optional Chaining Operator

Each one of these approaches for traversing tree-like structures has its shortcomings. Surely there is a better solution?

Enter the optional chaining operator.

The optional chaining operator is a binary operator that returns undefined if its left operand is undefined or null, else returns the value of its right operand.

//  if `a` is `undefined` or `null`:
// return `undefined`
// else:
// return `a.b`
a?.b;
// The optional chaining operator is equivalent to:
(a == null) ? undefined : a.b;

(Definition adapted from https://github.com/tc39/proposal-optional-chaining)

In other languages this type of operator is known as a safe navigation operator, existential operator, null-conditional operator, or null propagation operator.

The optional chaining operator can be composed to traverse tree-like structures of optional properties elegantly: if any intermediate property is null or undefined, the entire chain will evaluate to undefined.

Returning to our earlier example now using an optional chaining operator:

const city = apiResult?.office?.primary?.city;
// --> string | undefined

Voila! This statement is by far the most straightforward solution to our problem. It is concise, easy to read, and preserves all development-time tooling benefits.

What’s the catch? TypeScript unfortunately does not yet support an optional chaining operator. While there is longstanding community interest, TypeScript is waiting for clarity from TC39 on the direction JavaScript will ultimately take with respect to optional chaining support. The good news is that a proposal for adding an optional chaining operator to a future release of the JavaScript spec is currently under discussion.

Introducing ts-optchain

As a workaround for the lack of an optional chaining operator in TypeScript, we developed the open-source ts-optchain library at Rimeto. The goals of this library are:

  • Use a syntax that closely mirrors chained property access
  • Offer a concise expression of a default value when traversal fails
  • Enable IDE code-completion tools and compile-time path validation

Returning to our earlier example, we can now use ts-optchain to extract city safely:

The ts-optchain library prevents TypeError's when traversing invalid paths by overriding the wrapped object’s property getters using Proxy to emulate an optional chaining operator. The library also exports a set of TypeScript type definitions to support code-completion tools and compiler type checking during development.

Additional Examples

ES2015 Proxy

The implementation of the optional chaining (OC) Proxy looks as follows:

The oc function returns an new instance of an OC Proxy that targets a closure holding the optionally chained value. Since the OC Proxy overrides the default property getter to support chaining of OC Proxy’s, the closure allows OC Proxy to disambiguate optional chaining vs property access operations, namely:

const a = {
b: 'world'
};
// Optional chaining operation
oc(a).b; // --> OCType<string>
// Property access operations:
oc(a).b(); // --> string | undefined
oc(a).b('default'); // --> string

As illustrated above, to dereference the underlying value of the OC Proxy, one directly invokes the OC Proxy as a function. As a convenience, we can pass a default value to the closure to be returned if the underlying value is undefined (e.g., we have traversed an invalid path).

To implement optional chaining, OC Proxy overrides the default property getters to specifically handle attempts to access unassigned or invalid properties on the object. Instead of returning the usual undefined, OCProxy instead returns a new instance of OCProxy, which for any properties accessed on it in turn returns another instance of OCProxy, and so on.

On the other hand, if the requested property does exist on the proxied object, we return that property's value wrapped with a new instance of OC Proxy. Any subsequent property access attempts on the new OC Proxy are now also fortified against TypeErrors’s.

By recursion, we can traverse any arbitrary path on an OC Proxy object without concern for the validity of the path.

TypeScript Typings

The ts-optchain library includes TypeScript typings. As we describe the key types exported by the library, we will highlight several powerful TypeScript typing features, including: conditional, intersection, and mapped types.

OCType<T>

OCType<T> is a recursively defined intersection type describing an object enabled for optional chaining:

type OCType<T> = IDataAccessor<T> & DataWrapper<T>;

Given an OCType<T> object, we can either: (a) access the underlying object value (if it exists) by invoking the data accessor; or (b) traverse to the next optional chaining object of type OCType<T[K]> via a property accessor. The interfaces IDataAccessor<T> and DataWrapper<T> define this behavior respectively.

IDataAccessor<T>

The IDataAccessor<T> interface describes the shape of the data accessor function present on every proxied object:

interface IDataAccessor<T> {
/**
* Data accessor without a default value. If no data exists,
* `undefined` is returned.
*/
(): Defined<T> | undefined;
/**
* Data accessor with default value.
* @param defaultValue
*/
(defaultValue: Defined<T>): Defined<T>;
}

Invoking this function on an OCType object will effectively "dereference" the object value at the current position in the chain. If no data exists at the current position (e.g., invalid path), the data accessor returns either (a) undefined or (b) the default value passed as the first parameter to the accessor function.

Note the Defined type used in the definition of IDataAccessor:

type Defined<T> = Exclude<T, undefined>;

Defined takes advantage of the TypeScript's useful Exclude predefined conditional type to exclude the undefined type from generic type T. We use Defined in conjunction with IDataAccessor to ensure that when a defaultValue is passed to the data accessor, the response type is never undefined. The data accessor otherwise could always potentially return undefined if a path traversal is invalid.

DataWrapper<T>

The DataWrapper<T> type defines the expected properties (if any) on the optionally chained object:

type ObjectWrapper<T> = { [K in keyof T]-?: OCType<Defined<T[K]>> };interface ArrayWrapper<T> {
length: OCType<number>;
[K: number]: OCType<T>;
};
type DataWrapper<T> = T extends any[]
? ArrayWrapper<T[number]>
: T extends object
? ObjectWrapper<T>
: IDataAccessor<T>;

DataWrapper also takes advantage of the TypeScript's conditional type functionality to select between the sub-types ArrayWrapper, ObjectWrapper, and IDataAccessor corresponding to array, object, and primitive types respectively.

Several interesting notes here:

  • ObjectWrapper<T> is a mapped type taking advantage of the -? modifier to remove any optional property denotations from the original object interface definition before wrapping all property values as proxied types OCType<Defined<T[K]>>. (Defined is used again here to strip any undefined's implicit from optional properties on T)
  • ArrayWrapper<T> also wraps its values in OCType<T> using the numeric index signature and explicitly exposes the length property innate to Array types. (Note that we do not expose Array methods forEach, map, etc. on the interface. This is to avoid the ambiguity created with IDataAccessor if we were to allow invocation of Array methods mid-traversal.)
  • Also note the use of T[number]: this notation conveniently extracts the element type U when T is defined as Array<U>.
  • Finally, for primitive types, we simply apply the IDataAccessor<T> interface as no further traversal should be attempted.

Conclusion

No good solution exists yet in TypeScript for safely traversing tree-like structures. While we wait for a better built-in solution, ts-optchain is Rimeto's approach to optional chaining that preserves all the benefits of the TypeScript compiler.

In particular, ts-optchain is integral to tform, our library for applying functional rules to transform JSON-like data structures into arbitrary shapes. The libraries have enabled our team at Rimeto to quickly define and deploy data transformers that map arbitrary and often inconsistent data to canonical shapes. In turn, these data transformers are essential to our building the Enterprise Graph from the many 3rd-party data sources we ingest. By open-sourcing these libraries, we hope that you can now also benefit from a more resilient way of parsing inconsistent data structures in TypeScript.

We eagerly await your thoughts and feedback!

References

Love TypeScript as much as we do? Come work with us!

--

--