DEV Community

Samuel Rouse
Samuel Rouse

Posted on • Edited on

Optional Chaining: Uses & Limits

ES2020 introduced ?., the optional chaining operator. It allows us to cut down on complex conditions and reduces the risk of errors when dealing with uncertain data structures. Let's take a look at some details.

Benefits

There are some really good uses of optional chaining. Often we can replace multiple lines of code, complex conditions, or interim values with this new operator.

Shorter Conditions

One major use of optional chaining is eliminating long conditions testing the existence of each layer in a deeply-nested object. This can substantially improve readability of conditional code in a project.

// Long, tedious testing of each layer
if (data && data.rows
  && data.rows[0]
  && data.rows[0].records
  && data.rows[0].records.includes(myRecordId)) {
  // ...
}

// Optional chaining to the rescue!
if (data?.rows?.[0]?.records?.includes(myRecordId)) {
  // ...
}
Enter fullscreen mode Exit fullscreen mode

Fewer Variables & Conditions

A simple but effective use of optional chaining is reducing the amount repetitive code or boilerplate when performing operations like Array.prototype.find(). Because find() may return an entry from the array or undefined, we use interim values and conditional logic to test for the existence of the found value before continuing. Again, optional chaining makes the code smaller and more readable.

// "Classic" find action
// Intermediate may be the found entry. If it exists, doAction on it.
const intermediate = collection
  .find(({ value }) => value === comparison);
if (intermediate) {
  intermediate.doAction();
}

// Now with fewer characters!
// Find the entry and, if it exists, doAction on it.
collection.find(({ value }) => value === comparison)?.doAction();
Enter fullscreen mode Exit fullscreen mode

There are times we find ourselves checking the existence of a function before executing it. Optional chaining can eliminate these conditions, too.

// Common pattern of optional callbacks
load(data);
if (options.loadedCallback) {
  options.loadedCallback(data);
}

// Now, simplified
load(data);
options.loadedCallback?.(data);
Enter fullscreen mode Exit fullscreen mode

Optional Chaining & Nullish Coalescing

Because optional chaining returns undefined when it hits a nullish value, you can use it along with nullish coalescing to get values or fall back to defaults.

// Old style short-circuiting and Logical OR for non-boolean types
const actionType = (options && options.actionType)
  || 'default';
const outputFormat = (options && options.formats && options.formats.output)
  || (options && options.formats && options.formats.default)
  || 'CSV';


// Optional Chaining with Nullish Coalescing
// Only nullish values fall through.
const actionType = options?.actionType ?? 'default';
const outputFormat = options?.formats?.output
  ?? options?.formats?.default
  ?? 'CSV';
Enter fullscreen mode Exit fullscreen mode

This combination of behaviors is especially helpful for falsy values. We could not safely use Logical OR (||) for booleans, as falsy values are rejected. Ternaries or additional logic are needed without optional chaining and nullish coalescing. These were easy places to introduce defects accidentally, as more code and more care was required to achieve the correct result.

// Undefined checks and ternaries for boolean values
const isEnabled = (options && options.enabled !== undefined)
  ? options.enabled
  : true;

// If you forget it is boolean, you can introduce errors
// isValid will never be falsy
const isValid = (options && options.valid) || true;


// Optional Chaining with Nullish Coalescing for Booleans
// Only nullish values continue to the default.
const isEnabled = options?.enabled ?? true;
const isValid = options?.valid ?? true;
Enter fullscreen mode Exit fullscreen mode

Pitfalls

It's easy and satisfying to sweep through code and replace complex conditions and interim variables with optional chaining, but we have to be wary of how the resulting value is used or we can cause syntax errors or difficult-to-find defects.

Falsy & Negative Tests

This is the core concern of optional chaining, particularly when converting complex conditions. Because optional chaining will return undefined when it hits a nullish value, we have to ensure our code accounts for this.

// Long, tedious testing of each layer
// finally gets to a negated condition
if (data && data.records
  && !data.records.includes(myRecordId)) {
  // True *only* if data.records
  //  does not include myRecordId
}

// Optional chaining: rescue or regression?
if (!data?.records?.includes(myRecordId)) {
  // True if *any* layer does not exist
  //  OR it does not include myRecordId
}
Enter fullscreen mode Exit fullscreen mode

Our simplified falsy test now has three success conditions, where the original only had one. We can fix this by changing from negation to an equality check.

if (data?.records?.includes(myRecordId) === false) {
  // Back to matching the original test
}
Enter fullscreen mode Exit fullscreen mode

Key Takeaway: Use optional chaining with Logical Not (!) and Inequality (!=, !==) only with careful consideration.

Missing the Invocation

If you are performing method calls on an object with optional chaining, you might need to include optional chaining between the method name and the function invocation, as well.

In our previous examples we assumed that if data.records exists it is a data type that has the includes method. But includes may not exist if records is a different type, so calling .includes() will throw an error. We can protect ourselves from these errors using optional chaining between the method name and the invocation.

if (data?.records?.includes?.(myRecordId) === false) {
  // Better than before
}
Enter fullscreen mode Exit fullscreen mode

This can't protect from all errors. If you try to execute a value rather than a method, you would still get an error.

const data = {
  simple: 2,
  complex: {
    value: 3
    toString: "three",
  },
};

data.missing?.toString?.();   // undefined
data.simple?.toString?.();    // "2"
data.complex?.toString?.();   // TypeError
Enter fullscreen mode Exit fullscreen mode

Too Much Optional Chaining

It can be easy to use optional chaining to eliminate errors when data objects do not match a spec or cause test failures, but we should keep in mind the intended behavior of the project. Sometimes code should throw errors when it finds itself in an unexpected state.

Using optional chaining as a go-to solution for thrown errors can introduce new defects as incorrect data structures create unexpected paths through the code.

When you find yourself resolving a defect by adding optional chaining, make sure to ask if this should truly be optional. Is an incorrect object being created elsewhere in the code, or delivered by an incorrect API response? What are the consequences of "accepting" this unexpected data?

There are plenty of times that optional chaining will be the best solution. Just keep in mind those times when the quick solution isn't the right one.

Edge Cases

These are a few considerations that are rare enough you can probably forget them without worrying, but you may also find them interesting.

Operator Precedence

Optional chaining is high up on the operator precedence list. It is handled before Logical Not (!), typeof, math and comparison operations. It even happens before the new constructor if no arguments are provided. While it may see little use, this particular pairing can cause syntax errors.

// SyntaxError: Constructors in/after an Optional Chain are not allowed.
new Date?.toString();

// Returns the string value as expected
new Date()?.toString();

// If you hate instantiating with empty parentheses, this works, too.
(new Date)?.toString();
Enter fullscreen mode Exit fullscreen mode

A more realistic example might center around testing a received constructor for behaviors before using it. Still pretty far-fetched, but it is good to be aware of this edge case.

// This throws a syntax error
if (typeof new Constructor?.toJSON === 'function') {

// This works as expected
if (typeof new Constructor()?.toJSON === 'function') {
Enter fullscreen mode Exit fullscreen mode

Template Literals: Tagged Functions

Most people won't end up using tagged template literals in general. In the rare case of invocation with a template literal, trying to use optional chaining with throw a syntax error.

console.log`Test ${1}`;    // [ 'Test ', '' ], 1

console.log?.`Test ${1}`;  // Syntax Error: Unexpected token
Enter fullscreen mode Exit fullscreen mode

Learn More

The MDN article on Optional Chaining has many more examples and details, so I encourage you to take a look. Be mindful that some of the examples may violate your project's coding standards, best practices, or sometimes common sense (I'm looking at you, let prop = potentiallyNullObj?.[x++];), but knowing all the capabilities helps you choose which ones make sense for your project or team.

Top comments (0)