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)) {
// ...
}
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();
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);
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';
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;
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
}
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
}
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
}
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
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();
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') {
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
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)