Why I avoid `get`-like functions in JS

reyronald

Ronald Rey

Posted on March 10, 2020

Why I avoid `get`-like functions in JS

Photo by Brazil Topno on Unsplash

Because they are not statically analyzable.

Let's unpack.

First, let me clarify what I mean with that title. I'm referring to the type of functions that receive a path to an object property in the form a string, and return the value at that property or a fallback default one. For example:

const typeName = get(response, 'data.type.name', null)
// ☝ instead of doing `const typeName = response.data.type.name`
Enter fullscreen mode Exit fullscreen mode

There are many implementations of this pattern in very popular JavaScript libraries out there and I've seen it all over the place, including very high-profile projects, despite me considering it a very bad idea. You might remember it from lodash's get. Immutable also has its own version of the same concept with getIn.

These functions exist as a convenient way of reaching a value in a nested property of an object in a safe manner. In many cases it is common to have an object that is only partially defined, and trying to access any given property in it may cause the program to fail.

const response = {}
const typeName = response.data.type.name
// ❌ Uncaught TypeError: Cannot read property 'type' of undefined

// πŸ‘† That would be a runtime error happening in the app
//    when that code is executed. Crashing it.
Enter fullscreen mode Exit fullscreen mode

To avoid that, the developer should make sure that all the properties in the path are defined before actually trying to access them. The vanilla way of achieving this would be something like:

let typeName = null
if (response && response.data && response.data.type) {
   typeName = response.data.type.name
}
Enter fullscreen mode Exit fullscreen mode

So yeah, needless to say, a utility function that abstracts away all the redundant ugliness is much welcomed. So what's the problem with this type of get function, as I defined it above?

It is not type-safe.

With a type system in place, like TypeScript or Flow, we would have a type alias or interface that defines the shape of the object we are working with. The compiler uses that metadata to find bugs in your code when you are accessing and manipulating those objects, so it would be able to warn us when we try to do something that would end-up in a TypeError like the one we saw above.

type MyResponseType = {
  data?: {
    type?: {
      name: string
    }
  }
}

function main(response: MyResponseType) {
  const typeName = response.data.type.name
  //                         πŸ‘†
  // TypeScript: ❌ Object is possibly 'undefined'.

  // Compilation error happening at build or development time,
  // not when the app is running.
  return typeName
}
Enter fullscreen mode Exit fullscreen mode

However, when you do that property access through a string path, you are killing the compiler's ability to analyze your code, understand your intention and provide helpful advice BEFORE your app is deployed and running. The real problem arises when we start considering the implications of that beyond our immediate example from above.

If we rewrite that snippet to use the vanilla approach our compilation error is gone and we can now build and run our app. Let's see what happens if we introduce a type alias update.

type MyResponseType = {
  info?: { // πŸ‘ˆ Rename `data` -> `info`
    type?: {
      name: string
    }
  }
}

// ...

let typeName = null
if (response && response.data && response.data.type) {
   typeName = response.data.type.name
   // TypeScript: ❌ Property 'data' does not exist on type 'MyResponseType'.
}
Enter fullscreen mode Exit fullscreen mode

TypeScript can recognize that the properties we are trying to access do not match the contract we've defined for this object and therefore this would undoubtedly fail at runtime, but we are getting this very informational heads up from the type system.

Had we been using a more dynamic approach like the one suggested by the utility functions we're discussing, this mistake would've been completely invisible to the compiler and our app would've built like there's no problem at all, when in fact we've unknowingly introduced a bug, or worse, several bugs all over the place.

type MyResponseType = {
  info?: { // πŸ‘ˆ Rename `data` -> `info`
    type?: {
      name: string
    }
  }
}

// ...

const typeName = get(response, 'data.type.name', null)
// TypeScript: Everything looking good chief!
Enter fullscreen mode Exit fullscreen mode

If you are working in a large organization with multiple development teams contributing to the same codebase this is an occurrence that could happen surprisingly frequently. Even if you are the single developer of an app, this will still happen eventually to any non-trivial codebase.

This is a terrible mistake that could cause very serious production crashes that your users would end-up being the victims of. The reputation of your product would be harmed and the engineering team would be the one to blame.

But most importantly, this also makes refactoring a nightmare and a very stressful endeavor to a developer or a team. Rewriting code that is not statically analyzable will have you introducing regressions all over the place and slowing down the whole process dramatically since every line of code changed will require a much more thorough review and manual testing.

This is fatal for a product since, in practice, this will freeze your codebase in time, tying it up to accumulating technical debt given that continuous improvement through refactoring becomes very dangerous, risky and intentionally avoided by both the development team and the business team.

Then, given enough time, codebase becomes such an untouchable mess that it requires an entire re-write if any thought of sustainable progress is expected, causing the organization considerable and preventable losses.

The root of the problem

I blame the dynamic nature of the JS language that made this type of API design common-place throughout its maturing process. In other more strict languages working on the implementation of this get-like function would've been more tricky, motivating developers to come up with a more robust type-safe approach instead.

Had this function been designed with a more functional mindset, it could've been avoided easily. Just for illustration purposes, take a look at this alternative API that achieves the same goal, without losing type-safety.

function get<T>(fn: () => T, defaultValue: T): T {
  try {
    const result = fn()
    return result
  } catch (error) {
    return defaultValue
  }
}

// ...

const typeName = get(() => response.data.type.name, null)
Enter fullscreen mode Exit fullscreen mode

What I recommend

Use the optional chaining operator.

It is available in TypeScript, Babel, even plain JS in Node.js 12 and above and all the latest versions of the most popular browsers. So you can now just do:

const typeName = response?.data?.type.name ?? null
Enter fullscreen mode Exit fullscreen mode

No libraries. No superfluous functions. No plugins. Just plain JavaScript.

Do it even if you are not using any type system. Some code editors and IDEs can still provide rudimentary type-safe support to plain JS files, and if you eventually do integrate a type system, you'll get that coverage for free.

If for some reason you are working in an environment where you can't use the optional chaining (can't upgrade TypeScript/Babel, an old version of Node, have to support old browsers and have no compilation tools, etc.), then maybe opt to use the functional get alternative I used as an example above, but I would argue you have bigger problems to take care of!

πŸ’– πŸ’ͺ πŸ™… 🚩
reyronald
Ronald Rey

Posted on March 10, 2020

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related

Message queue with Socket.io
javascript Message queue with Socket.io

May 6, 2024

Hoisting
javascript Hoisting

December 18, 2022

Create a simple Node Server Skeleton.
javascript Create a simple Node Server Skeleton.

December 16, 2022