Refactoring optional chaining | Dailygrind


Optional Chaining is a relatively new feature in ECMAScript and perhaps not a novelty in other languages such as Kotlin or Java. Simply put, it allows developers to safely reference properties and methods located deep into an object without incurring in an exception.

const client = {};

console.log(client.details.address);
// Uncaught TypeError: client.details is undefined

console.log(client.details?.contacts?.mobileNumber); 
// undefined

Optional chaining can also be applied to execute conditional function calls:

client.portfolio?.calculatePortfolioValue?.();
// undefined

You have almost certainly dealt with objects exposing a large number of public properties (ie: large json objects from a server response) before and realising not all properties are available at all times. This is a common scenario, especially in legacy codebases. In order to work with the object’s api and data, a common solution to this problem is to wrap your code in a null-check.

if (client && client.details && client.details.contacts) {
  // work safely with contacts
}

While adding checks to ensure the object has a certain shape is a safe way to avoid exceptions that may break the execution of the program, it certainly raises questions on whether working with an unknown shape paves the way to antipatterns, compromises purity of your functions or the readability of your code.

Consider the following:

const calculatePortfolioValue = portfolio =>
  portfolio?.products?.reduce?.(
    (accumulator, product) => accumulator + product?.amount, 
  0) || 0;

This function sums the amounts of the products, within a client’s porfolio object. At first glance, I would immediately notice the ? noise and would probably focus on what the function actually returns. If you are not working within a typed system, this may be an area
where you fould focus more time then it is actually needed. After all, it’s just a reduce function, but the possible return value is obscure.

Different points of failure

The function calculatePortfolioValue expects an arbitrary portfolio object argument. We can deduce its shape by reading the function body:

  • It may have a products property
  • If it does, it may implement an interface that includes a reduce method
  • If it does, then it must implement an accumulator function that works with an object that may expose an amount property

The flow above exposes different points of failure. In particular, the function will return 0 in cases where a property is missing or spelt wrong, complicating a debugging session further.

Below is an example:

const portfolio = {
  products: [
    { name: "p1", amount: 2 },
    { name: "p2", amount: 3 },
    { name: "p3", amuont: 4 }, // <= typo
    { name: "p4", amount: 5 }
  ]
};

calculatePortfolioValue(portfolio);
// 0

The function calculateValue will fail gracefully thanks to optional chaining however, because of a typo on the product property amount at index 2, the sum is 0. The error is not reported and we may not realise the problem right away.

Validating the inputs

Validating the inputs is preferrable here because it allows to capture the problem that occured during the execution of the program. Validating the inputs could be as simple as returning early with an exception:

const calculatePortfolioValue = (portfolio = {}) => {
  if (!Array.isArray(portfolio.products))
    throw new TypeError("Portfolio is missing the list of products");

  let portfolioValue = 0;
  for (const product of portfolio.products) {
    if (!Number.isFinite(product.amount))
      throw new TypeError("Portfolio product must have an amount");
    portfolioValue += product.amount;   
  }

  return portfolioValue;
};

calculatePortfolioValue(portfolio); // TypeError

A defensive approach such as the above is verbose and may not be ideal if the validation is complex. In some cases breaking the data flow may also require more defensive programming later in the code. You might want to consider validating the inputs against a schema using tools such as Yup or Joi.

const calculatePortfolioValue = (portfolio = {}, schema) => {
  const { error } = schema.validate(portfolio);
  if (error) 
    throw new TypeError("Portfolio must validate against a schema");
   /// ....
};

A functional apprach

A good practice using a functional approach is not to throw exceptions directly, but to collect errors so that they can be handled down the chain. The goal can be achieved by reducing the scope of calculatePortfolioValue and work towards purity as far as possible.

One immediate benefit of this approach is testability. The functions involved in the computation can be isolated, tested separately and perhaps reused:

const getProducts = (portfolio) =>
  Array.isArray(portfolio.products)
    ? [portfolio.products, []]
    : [[], [new TypeError("No available products")]];

const getProductValue = (product) =>
  Number.isFinite(product.amount)
    ? [product.amount, []]
    : [0, [new TypeError(`No available amount for ${product.name}`)]];

The example below uses lodash/fp, but the same result could have been achieved with any functional library, or even without one:

import {
  flow,
  map,
  concat,
  reject,
  isEmpty,
  flatten,
  curry,
  nth
} from "lodash/fp";

const getProductValues = ([products, errors]) =>
  [map(getProductValue, products), concat([], errors)];

const calculateProductsValue = ([products, errors]) => [
  products.reduce((acc, [amount]) => acc + amount, 0),
  flatten(concat(reject(isEmpty, map(curry(nth(1)), products)), errors))
];

const getPortfolioReport = flow([
  getProducts,
  getProductValues,
  calculateProductsValue
]);

const [value, errors] = getPortfolioReport(portfolio);
// value = 10
// errors = [TypeError] <= the amount typo!

One might wonder what this line actually does:

flatten(concat(reject(isEmpty, map(curry(nth(1)), products)), errors))

It collects the errors from the product values and combines them with previous errors in a flattened array. Using Either monad constructs would have probably been a better option to manage the failures here: some functional libraries such as Falktale offer this functionality out of the box.

In Conclusion

Optional chaining is indeed a powerful feature but a widespread usage might cover some underlying typing problem. In general, it is a good practice to avoid such scenario by validating the inputs or by collecting the type problems using a functional approach.

Useful Resources




Source link

Latest articles

Related articles

Leave a reply

Please enter your comment!
Please enter your name here