Why I avoid `get`-like functions in JS
Ronald Rey
Posted on March 10, 2020
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`
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.
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
}
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
}
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'.
}
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!
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)
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
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!
Posted on March 10, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.