Build a pathOr Utility in JavaScript

7 min read

Accessing properties of deeply nested objects in JavaScript is potentially dangerous. You need to code defensively to avoid the dreaded Cannot read properties of undefined error.

In this article, we're going to build a handy little utility for safely accessing a property of a nested object.

Let's start by outlining our goal. We want to create a utility function that defines three parameters:

  1. A default value
  2. An array of keys the define the path to the desired value
  3. An object that can be nested to any depth

With those parameters, we want to use the supplied path to get the value of the final key and if at any point we can no longer follow the path (because something is undefined), we should return the default value.

This means we'd invoke our function like so:

const result = pathOr('default value', ['b', 'c', 'd', 'e'])

And if we had an object like this:

const input = {
  a: 'A',
  b: {
    c: {
      d: {
        e: 'E',
      },
    },
  },
}

We would get the result of E. But if we had an object that didn't include that full path, say the object was more like this:

const safeInput = {
  a: 'A',
  b: {
    c: {
      d: {},
    },
  },
}

Then the return value would be default value because that's the value we passed in as the first argument.

With all of that laid out in front of us, let's define a couple of unit tests to guide us through the implementation.

Initial Test Cases

We'll define two inputs, safeInput which will give us the resulting value and dangerInput which should result in our default value since the path will be unreachable. We'll test the pathOr utility with the same default value and paths, and we'll change up the input and expected result accordingly.

describe('pathOr', () => {
  const safeInput = {
    a: 'A',
    b: {
      c: {
        d: {
          e: 'E',
        },
      },
    },
  }

  const dangerInput = {
    a: 'A',
    b: {
      c: {},
    },
  }

  it('returns the expected value when defined', () => {
    const safeResult = pathOr('nope', ['b', 'c', 'd', 'e'], safeInput)
    expect(safeResult).toBe('E')
  })

  it('returns the supplied default when the path is undefined', () => {
    const dangerResult = pathOr('nope', ['b', 'c', 'd', 'e'], dangerInput)
    expect(dangerResult).toBe('nope')
  })
})

Defining the pathOr utility

Let's start with the function shell

export function pathOr(defaultValue, path, data) {
  // make it work
}

With our parameters defined, we can add the implementation. In this case, we're going to trust that the path provided is an array of strings.

Since path will be an array, we'll start by using the reduce method on it to traverse the path. We'll provide a reducer function which we'll implement in the next steps, and we'll provide our input object (data) as the initial value for the accumulator.

export function pathOr(defaultValue, path, data) {
  return path.reduce((acc, item) => {
    /*make it work*/
  }, data)
}

From here, we can tackle the happy path by returning the value from our accumulator where the element from the array (item) is the key.

export function pathOr(defaultValue, path, data) {
  return path.reduce((acc, item) => acc[item], data)
}

This will pass our happy path test by iterating over the path elements, getting that key from the object which will be used as the new accumulator on the next iteration. When there are no elements remaining, we've reached the key at the end of the path and that is returned from the reduce call, which is what we ultimately return from our function.

Of course, this will break if the path can't be reached, so let's round out our implementation to handle the unhappy path.

For this we need to check the next item in the path and if it exists, we return it just as we did before, if it doesn't exist we return the default value.

export function pathOr(defaultValue, path, data) {
  return path.reduce(
    (acc, item) => (acc[item] ? acc[item] : defaultValue),
    data
  )
}

This will get the job done, but by using reduce here, we'll end up iterating over each element in the path, regardless of how early in the path we need to fall back to our default value. I'm not typically one to prematurely optimize code, but given the possibility that we could fallback in the first iteration of a path of undetermined length, we should look at a more optimized approach.

Let's build a new version of this that skips unnecessary iterations. Since our unit tests aren't concerned with the implementation, we can refactor this function and the existing tests will let us know if we missed the mark.

We'll replace our reduce method with a for of loop and we'll create a variable to hold our result, defaulting it to the data value. In each iteration of the loop, we'll check for the path segment as a key and if it exists, we'll reassign result with the new value. If it doesn't exist, we'll assign defaultValue to result and break out of the loop. In each case, we'll return result once the loop has been exited.

export function pathOr(defaultValue, path, data) {
  let result = data
  for (const segment of path) {
    if (result[segment]) {
      result = result[segment]
    } else {
      result = defaultValue
      break
    }
  }
  return result
}

Supporting String Paths

Some utility libraries offer similar functionality where you can provide your path as a string using the familiar dot notation that you might typically think of when accessing nested properties.

Let's add a couple new tests for this utility and refactor it to support the additional approach.

Additional Tests

We'll add a couple unit tests to our existing describe block and pass a dot notation string in place of the array for the path argument.

it('can support a string path too', () => {
  const safeStringResult = pathOr('nope', 'b.c.d.e', safeInput)
  expect(safeStringResult).toBe('E')

  const safeShortResult = pathOr('nope', 'a', safeInput)
  expect(safeShortResult).toBe('A')
})

it('handles undefined paths with strings too', () => {
  const dangerStringResult = pathOr('nope', 'b.c.d.e', dangerInput)
  expect(dangerStringResult).toBe('nope')

  const dangerShortResult = pathOr('nope', 'f', dangerInput)
  expect(dangerShortResult).toBe('nope')
})

Refactoring the Code

At this stage, both new tests will fail, so let's refactor the code to make them pass. We can largely keep our code intact. All we need to do is determine if we have an array or a string. If we have a string, we'll convert it to an array with split and pass our new array to the existing loop.

export function pathOr(defaultValue, path, data) {
+ const pathSegments = Array.isArray(path) ? path : path.split('.')
  let result = data
- for (const segment of path) {
+ for (const segment of pathSegments) {
    if (result[segment]) {
      result = result[segment]
    } else {
      result = defaultValue
      break
    }
  }
  return result
}

Of course, we have no type checking or validation of the input, so there is work to be done before this would be production worthy, but it covers most of the behavior we set out to achieve at the start of this article.

Wrap Up

Modern JavaScript and TypeScript provide optional chaining and libraries like lodash and ramda contain utilities that do this very thing, so you likely won't ever need to write your own. Still, this sort of exercise is a good way to exercise different APIs and think through a problem that might seem more complex than it really is at first glance. If nothing else, it can be fun to tinker with code and use that as a way to keep your interviewing skills sharp.