Optional chaining in depth

Optional chaining in depth

ยท

8 min read

TLDR; Optional chaining (?.) prevents runtime errors when accessing properties or methods of potentially null or undefined objects by short-circuiting the expression and returning undefined instead. It's an abstraction over null or undefined checks and improves code readability.

๐Ÿ’ก
The optional chaining (?.) operator accesses an object's property or calls a function. If the object accessed or function called using this operator is undefined or null, the expression short circuits and evaluates to undefined instead of throwing an error. - MDN

There are several occasions where you are working on deeply nested object properties(It may be from server or you may have defined it) and some of the property can be undefined or null. If you try to access a property on a null or undefined then this may break your program.

const user = {
  name: "decpk",
  address: {
    home: {
      street: {
        number: 16,
        name: "High street",
      },
    },
    office: {},
  },
};

console.log(user.address.home.street.name); // "High street";
console.log(user.address.office.street.name); //  TypeError

First console works but not second because we are trying to access name property on undefined value which leads to following error

We can easily resolve this error by adding checks like:

if(user.address.office.street) {
    console.log(user.address.office.street.name); // โœ… Now this works
}

But what if we are getting this user object from server and it may be present and may not by or what if user object doesn't have address property or office property in address object then we have to add check for them also ๐Ÿ˜ณ

Now after adding check our code looks like:

if (
    user && 
    user.address && 
    user.address.office && 
    user.address.office.street && 
    user.address.office.street.name
) {
    console.log(user.address.office.street.name); 
}

This doesn't looks good right?

These checks are for Falsy value not specifically for null or undefined. What if user.address is empty string("") then that will short circuit also results in "" which may not be desirable behaviour. This applies to other Falsy values also like 0, NaN, -0 etc.

๐Ÿ’ก
A nullish value is the value which can either be null and undefined. All nullish values are Falsy values but this may not be true in vive-versa case.

We only have to short circuits if and only if value is null or undefined.

Here comes Optional Chaining. The above code snippet looks elegant when we introduce optional chaining as:

console.log(user?.address?.office?.street?.name); // undefined

This feels good but what does it do?

In this code snippet we are checking existence of each property/object, if that property or object exist then the value of that expression is returned else the evaluated value will be undefined.

First we are checking, if user is null or undefined, If it does then short circuits and return undefined else whole user object is evaluated as its value.

then, we are checking if user.address is null or undefined, If it does then short circuits and return undefined else whole user.address object is evaluated as its value.

user?.address
user === null || user === undefined ? undefined : user.address;

Both the above expression are same. This apply to all level wherever optional chaining is used.

๐Ÿ’ก
I'd prefer to say that optional chaining is an abstraction over checks of null or undefined.

Where else can we use optional chaining.

So far we have only seen for accessing a property. But what if the property is not an object instead it is a function or an array. Does it works with them too? Let's check it out.

Optional chaining in functions/methods

Take an example where an optional method is defined on an object like:

const obj = {
    nestedObj: {
        method() {
            return {
                value: "someValue"
            }
        }
    }
}
const value = obj.nestedObj.method().value; // "someValue"
const someValue = obj.nestedObj.someMethod().value; // TypeError

In the above snippet, method is available but not someMethod. So if you trying to access it it will throw a TypeError

So to handle we can use optional chaining here:

const obj = {
    nestedObj: {
        method() {
            return {
                value: "someValue"
            }
        }
    }
}
// Optional chaining for method
const someValue = obj.nestedObj.someMethod?.().value;

But this doesn't guarantee for existence of nestedObj. It may or may not exist. To check for its existence, you also have to specify optional chaining for nestedObj too.

const someValue = obj.nestedObj?.someMethod?.().value;

Optional chaining in an array

We can also use optional chaining in arrays also if we try to get value at index which doesn't exist

const arr = [
    { value: "first"}, 
    { value: "second"},
]

const valueAtSecondIndex = arr[2].value;
// TypeError: Cannot read properties of undefined (reading 'value')
console.log(valueAtSecondIndex);

Since, index 2 is not present so if we try to access it and get value from it. It will leads to runtime error.

We can handle this using optional chaining as:

const valueAtSecondIndex = arr[2]?.value; // undefined

There are some cases where optional chaining tricks you and you won't get result what you are expecting.

Expression is evaluated till it short-circuit

In the beginning of this article I've already mentioned that expression short circuits and evaluates to undefined instead of throwing an error. So if there are expression post short-circuit they won't get evaluated. Let see with an example:

let index = 0;
const arr = [
    { value: [100, 200] }, 
    { value: [300, 400] },
]

console.log(arr[2]?.value[++index]); // undefined
console.log(index); // 0

You might expect index as 1 but it will be 0 because it short circuits till arr[2] which is undefined. Further expression won't get evaluated.

Below 2 expression are same:

console.log(arr[2]?.value[++index])
console.log(arr[2] === null || arr[2] === undefined ? undefined: arr[2].value[++value])

Control won't reach till the ++index expression so it won't get evaluated.

Short circuit won't be continue after group expression

Let say you are grouping some expression and try to use optional chaining in it and then try to access a property which doesn't exist then it will break.

const user = {
    address: {
        home: {
            number: 100,
            street: "High street",
        }
    }
}
// TypeError: Cannot read properties of undefined (reading 'name')
const result = (user?.bank).name;

You might expect that this will works fine but it won't because optional chaining is added in user?.bank group which result in undefined but it will still try to access name property on it.

Below both expression are same

(user?.bank).name
(user === null || user === undefined ? undefined : user.bank).name

So after short circuit from group it returns undefined

(undefined).name

So this will still try to access name on group expression which is undefined which leads to runtime Type Error.

Disadvantages of optional chaining

  1. Increased Complexity: Optional chaining introduces additional syntax, potentially making code harder to follow. For example:

     // Complex optional chaining
     const result = obj?.prop1?.prop2?.method?.();
    

    Here, obj may or may not have prop1, prop2, or method, and each property or method is accessed conditionally.

  2. Potential Misuse: Overusing optional chaining can lead to confusing code. Consider:

     // Unnecessary optional chaining
     const name = person?.name?.first?.toLowerCase();
    

    If person or person.name is undefined, optional chaining prevents errors. However, applying .toLowerCase() to an undefined value is unlikely to be useful.

  3. Performance Overhead: Each optional chain adds a small performance cost. For instance:

     // Performance overhead
     const value = obj?.method()?.prop;
    

    Here, each ?. introduces a runtime check, potentially slowing down execution compared to direct property access.

  4. Compatibility Concerns: Optional chaining might not be supported in all environments. For example:

     // Compatibility concern
     const age = user?.details?.age;
    

    Older JavaScript environments or certain configurations may not recognise optional chaining syntax.

  5. Debugging Challenges: Extensive optional chaining can complicate debugging. Consider:

     // Debugging challenge
     const data = obj?.prop1?.method?.() ?? defaultValue;
    

    When data is unexpectedly undefined, it can be harder to determine which link in the chain failed.

  6. Masking Underlying Issues: Optional chaining may hide underlying problems:

     // Masking issues
     const result = data?.records?.[0]?.name;
    

    If data or data.records is unexpectedly undefined, optional chaining prevents errors but may obscure the root cause.

In summary, while optional chaining is valuable for handling potentially missing properties or methods, developers should be mindful of its impact on code readability, performance, compatibility, debugging, and the potential to mask underlying issues.

Alternative of Optional chaining

We can create a generic method which will return us normalised value from nested object. Below is a simple example:

type NestedObject = { [key: string]: any };

function getValueByPath(obj: NestedObject, path: string): any {
    const keys = path.split('.').map(part => {
        const match = part.match(/^(\w+)(?:\[(\d+)\])?$/);
        if (match && match[2] !== undefined) {
            return { key: match[1], index: parseInt(match[2]) };
        } else {
            return { key: part, index: undefined };
        }
    });

    let value: any = obj;
    for (const { key, index } of keys) {
        if (value && typeof value === 'object') {
            if (index !== undefined && Array.isArray(value)) {
                value = value[index];
            } else if (key in value) {
                value = value[key];
            } else {
                return undefined; // Property not found
            }
        } else {
            return undefined; // Property not found
        }
    }
    return value;
}

// Example usage:
const obj = {
    a: {
        b: {
            c: [{ d: 123 }]
        }
    }
};

console.log(getValueByPath(obj, 'a.b.c[0].d')); // Output: 123

The function getValueByPath is designed to retrieve a nested value from an object using a dot-separated path string. It parses the path string and navigates through the nested structure of the object to find the desired value. The function also supports array index notation within property names directly, allowing for more complex paths.

That is all about Optional chaining. Hope it helps ๐Ÿ˜Š

Did you find this article valuable?

Support decpk by becoming a sponsor. Any amount is appreciated!

ย