JavaScript Refactoring Gotchas: 5 Ways Converting to Optional Chaining Can Break Your Code

JavaScript Refactoring Gotchas: 5 Ways Converting to Optional Chaining Can Break Your Code

Converting to optional chaining can seem like a no-brainer, but there are a few things to watch out for.

The optional chaining operator .? returns the value of an object property when the object is available and undefined otherwise. It is similar to the standard . chaining operator, with an added check if the object is defined (i.e., not nullish).

The optional chaining operator lets you write concise and safe chains of connected objects when some of those objects can be null or undefined. Before the introduction of optional chaining in ES2020, the && operator was often used to check if an object is available (obj && obj.value).

You can often simplify existing checks with the optional chaining pattern:

  • obj && obj.property becomes obj?.property
  • obj != null && obj.property becomes obj?.property
  • obj != null ? obj.property : undefined becomes obj?.property
  • arr && arr[i] becomes arr?.[i]
  • f && f() becomes f?.()
  • etc.

However, there are some cases where refactoring into optional chaining can lead to bugs:

Optional chaining short-circuits for nullish values, but not for other falsy values

When a && a.b is replaced with a?.b, the execution for types that can have falsy values is changed. This means that the result value and type of the expression can be different with optional chaining.

The following snippet shows some examples:

function test(value) {
    console.log(`${value && value.length}, ${value?.length}`);
}

test(undefined);       // undefined, undefined
test(null);            // null, undefined
test(true);            // undefined, undefined
test(false);           // false, undefined
test(1);               // undefined, undefined
test(0);               // 0, undefined
test({});              // undefined, undefined
test([]);              // 0, 0
test({ length: "a" }); // a, a
test('');              // , 0
test(NaN);             // NaN, undefined

The empty string, which is falsy, but not nullish, can be especially problematic. Here is an example were introducing optional chaining can lead to problems:

// without optional chaining
if (s && s.length === 0) {
  // not called for the empty string 
  // (e.g., legacy code that works this way)
}

// with optional chaining
if (s?.length === 0) {
  // called for the empty string 
  // (potentially introducing undesired behavior)
}

Optional chaining changes the result for null to undefined

When calling a?.b with null, the result is undefined. However, with a && a.b, the result is null.

Optional chaining can affect the number of calls with side effects

For example, consider changing

f() && f().a;

into

f()?.a;

With &&, f is called one or two times. However, with optional chaining f is only called once. If f has a side effect, this side effect would have been called a different number of times, potentially changing the behavior. This behavior applies not just to function and methods calls but also to getters that can potentially have side effects.

TypeScript does not support optional chaining of the 'void' type

TypeScript does not support optional chaining for void, event though the corresponding JavaScript code would work.

type Input = void | {
    property: string
};

function f(input: Input) {
    // this works:
    console.log(input && input.property);
    // this breaks because void is not undefined in TypeScript:
    console.log(input?.property);
}

Old browsers and JavaScript engines do not support optional chaining

Optional chaining is an ES2020 feature. It is supported on all modern browsers and Node 14+, but for older browsers and Node versions, transpilation might be required (compatibility).