Object destructuring best practice in Javascript
A summary of the evolution of object property access patterns at Crunch.
At Crunch, one of the principles of our Engineering Manifesto is ‘build the right thing in the right way’. Among other things, this means that we put a strong emphasis on simplifying and standardising our tech stack, as well as promoting the use of code libraries and design patterns.
As part of that effort, the developers across our teams are encouraged to review the patterns we use within our codebase. If we find what seems to be a better way of doing something, after some discussion we agree on whether we should adopt that pattern for new or refactored work, to be used in place of, or along with what we already have in our toolbox. The move from ES5 to React/Redux/ES6+ a few years ago involved many of these decisions, one of which was to use ES6 destructuring of objects and arrays.
As we improve our knowledge, we adapt and update our destructuring patterns to something that our teams find easier to work with than what had previously been in place. This blog post is a short summary of where we started from with our object property access patterns, how we initially started to incorporate destructuring patterns, and how we continue to modify and evolve these patterns as our understanding grows.
What is Object Destructuring?
The destructuring assignment syntax is a JavaScript expression that makes it possible to unpack values from arrays, or properties from objects, into distinct variables. (MDN)
Object properties can be accessed by using the dot notation or the bracket notation:
const myObj = {
foo: 'bar',
age: 42
}myObj.foo // 'bar' accessed through dot notation
myObj['age'] // 42 accessed through bracket notation
The object destructuring assignment syntax gives us a third way to access object properties:
const { foo } = myObj // 'bar'
const { age } = myObj // 42// or all in one line:const { foo, age } = myObj // foo === 'bar', age === 42
Object Destructuring
Ok. First things first. Before ES6 object destructuring was even a thing, in addition to regular dot notation or bracket notation for object property access, we made great use of a utility function that we affectionately called getPropValue
It allowed us to pass in an object and a key, and return the value of that key from the object:
export const getPropValue = (obj, key) =>
key.split('.').reduce((o, x) =>
o == undefined ? o : o[x]
, obj)
(Note that the double equals ==
checks for both undefined
and null
)
We could pass in a key of the form prop.nestedprop.nestedprop
and it would happily return the value for that nested property.
For example, if the object were:
const obj = {
main: {
content: {
title: 'old pier',
description: 'seagulls paradise'
}
}
}
we could retrieve the title as:
const title = getPropValue(obj, 'main.content.title')
// 'old pier'
Very easy to use and well loved by all of our developers. One of the nice features of getPropValue
is that it handled the case where some nested property is undefined:
const obj = {
main: undefined
}
}const title = getPropValue(obj, 'main.content.title')
// undefined
or null:
const obj = {
main: null
}
}const title = getPropValue(obj, 'main.content.title')
// null
First level object destructuring
As much as we loved getPropValue
when ES6 object destructuring wandered into our office one day, all shiny and new whilst smelling of apples, somehow we knew, without saying it out loud, that things were going to change in our codebase. At first, we limited destructuring to one level.
For example:
const obj = {
main: 'Brighton seagull'
}const { main } = obj || {}
// 'Brighton seagull'
Also, we quickly learned to add the || {}
because destructuring fails when attempting to destructure from undefined
or null
as we can see in the example below:
// If a is undefined...> let a = undefined
undefined
> const { notAProperty } = a
TypeError: Cannot destructure property `notAProperty` of 'undefined' or 'null'.// but if we set a guard of an empty object...> a = undefined || {}
{}
> const { alsoNotAProperty } = a
undefined// Since alsoNotAProperty doesn't exist in the empty object {}, when we attempt to destructure, alsoNotAProperty gets set to undefined
Nested destructuring
After all teams got up to speed and comfortable with this initial one-level form of the object destructuring pattern, we collectively took it up a level to nested destructuring. We were still using getPropValue
at this stage. Probably not as much as previously though. It was almost to the extent that getPropValue
had an idea that something was up. We were not quite there yet. But the smell of apples was quite strong. What were we to do?
To destructure a nested property, we write it in a similar way to the shape of the object from which we are destructuring.
For example, to set title
to be a const
with value equal to the nested title
property within the obj
:
const obj = {
main: {
content: {
title: 'old pier',
description: 'a structure where once many people used to wander around leisurely. Now even the seagulls fly away.'
}
}
}const { main: { content: { title } } } = obj || {}
So main
is within obj
, content
is within main
, and finally title
is destructured from content
…. Phew. Note that we get a value for just the final step of destructuring, meaning the value we destructure for title
is assigned to the variable named title
. We don’t assign main
or content
to variables.
This all works fine as long as those properties all exist in the obj
and none of them are undefined
or null
.
When that’s not the case, we need to find a way to prevent errors such as the dreaded TypeError: Cannot destructure property ‘propertyName' of ‘undefined' or ‘null'
error.
More on how to deal with null
later.
First, let’s take a look at how to deal with undefined
. We can deal with undefined
through the use of defaults.
Setting defaults
If we want main
to have a default value to guard against undefined
, there are a few ways we can do that:
- Inline object defaults for each property:
We build it up, working from the outside in:
// First is 'main'. We can set a default of {} for 'main'
const { main = {} } = obj || {}// Next is 'content'. We can set a default of {} for 'content'
const { main: { content = {} } = {} } = obj || {}// Finally we have 'title'. We can set a default of 'defaultTitle' for 'title'
const { main: { content: { title = 'defaultTitle' } = {} } = {} } = obj || {}
Note: The above example shows how to set a default value for the title
property. We don’t ever set a default title with the value ‘defaultTitle’ in our codebase. The example is to demonstrate the concept, not to show actual usage.
This pattern is useful for when obj
does exist, but doesn’t contain the nested property that we want to destructure from it. Setting defaults at each level as in the example above allows us to deal with this case.
2. Inline default object for the main variable:
Here again we attempt to destructure main
from obj
. If obj
is undefined
or null
, then main
is destructured from {}
. This would cause it to be undefined
(since it doesn’t exist in {}
) but since we have set a default of defaultMain
, main
ends up being set to this. And since content
and title
exist in defaultMain
we can destructure them out without having to set inline empty object defaults.
const defaultMain = {
content: {
title: 'defaultTitle',
description: 'defaultDescription'
}
}
const { main: { content: { title } } = defaultMain } = obj || {}
This pattern is useful when obj
doesn’t exist. There’s a gotcha where obj
does exist but doesn’t contain all the properties necessary for the destructuring to work. (See the section below on Gotchas for more.)
3. Inline default object on the right:
Similar to case 2 above, but instead of having a guard against undefined
with an empty object, and setting a default of defaultMain
for main
, this time we put a literal default object in place of the || {}
const { main: { content: { title } } }
= obj || { main: { content: { title: 'defaultTitle' } } }
This has the same gotcha where obj
does exist but doesn’t contain all the properties necessary for the destructuring to work. (See the section below on Gotchas for more.)
4. Default object variable on the right:
Similar to case 3, but defining defaultObj
outside of our destructuring statement.
const defaultsMain = {
content: {
title: 'defaultTitle',
description: 'defaultDescription'
}
}
const defaultObj = { main: defaultsMain }
const { main: { content: { title } } } = obj || defaultObj
Once again, there’s the gotcha where obj
does exist but doesn’t contain all the properties necessary for the destructuring to work. (See the section below on Gotchas for more.)
Which nested object destructuring pattern is best?
We found pattern 1 useful for one or two levels of nested properties. Anything more than that and it gets difficult to read and to follow which default is being set for which property. However, it does prevent the gotchas mentioned earlier.
We used pattern 2 for a short while before quickly moving on to patterns 3 and 4.
We found pattern 3 useful for one or two levels of nested properties.
Pattern 4 helps to make things easier to read and reason about, especially for larger objects and when destructuring multiple properties at the same time.
As an example of destructuring multiple properties, here we destructure title
and description
from obj
, with a guard of defaultObj
if obj
doesn’t exist:
const defaultsMain = {
content: {
title: 'defaultTitle',
description: 'defaultDescription'
}
}
const defaultObj = { main: defaultsMain }
const { main: { content: { title, description } } } = obj || defaultObj
Gotchas
One thing to be wary of is the case where obj
does exist, but doesn’t contain the property we want to destructure. Patterns 2, 3 and 4 will fail for this case, even if the default we supply does have that property. Since obj
does exist, we don’t ever get to use the default.
const obj = {
main: {
content: {
title: 'old pier',
description: 'even the seagulls fly away.'
}
}
}const defaultMain = {
content: {
title: 'defaultTitle',
description: 'defaultDescription',
copy: [
'some copy text',
'some more copy text'
]
}
}const defaultObj = { main: defaultMain }// -------------------// this works (if we destructure from defaultObj)const { main: { content: { copy: [ firstLineOfCopy ] } } } = defaultObj// 'some copy text'// -------------------// this doesn't work (since obj does exist but contains null or undefined for at least one property on our way to destructure 'firstLineOfCopy'const { main: { content: { copy: [ firstLineOfCopy ] } } } = obj || defaultObj// TypeError: Cannot read property 'Symbol(Symbol.iterator)' of undefined
The best way we have found to deal with this gotcha is to set inline defaults for each level of the destructuring, as we did in pattern 1 earlier.
// this works !!! but it's starting to get difficult to readconst { main: { content: { copy: [ firstLineOfCopy ] = ['some text'] } = {} } } = obj || defaultObj
Defaults only apply to undefined, not to null
As promised earlier, let’s take a quick look at how we deal with the case where one or more of the properties within the object are null
.
Is there a way to destructure from an object that contains null
values and not have it fail?
Going back to an earlier example, let’s set content
to null
:
const obj = {
main: {
content: null
}
}
Our utility function getPropValue
can handle null property values without falling over and spilling coffee everywhere:
const title = getPropValue(obj, 'main.content.title')
// null
Object destructuring is a little trickier.
First, a bit of a refresher on what null
is. A value or property cannot be null
unless it has been explicitly set to null
. That is why default values apply to undefined
and not to null.
A property that has not been set is undefined
, but if it is null
it has been explicitly set to null
at some point, and javascript sees that as the intent of the developer and does not overwrite that value with a default value.
So this means that using our earlier technique of setting a default for content
would fail:
// if the content property in main in obj is null, this will failconst { main: { content: { title } = { title: 'default title' } } } = obj// TypeError: Cannot destructure property `title` of 'undefined' or 'null'.
To get around this, we can split it over multiple lines using || {}
as a guard against a falsy object value such as undefined
or null
(be careful if some other such as false,
0,
or ''
is a valid return value)
// if the content property in main in obj is null, this will still workconst { main } = obj || {}
const { content } = main || {}
const { title } = content || { title: 'default title' }// 'default title'
This works best if we know which property we can expect to have a null
value, since we’ll only need to ensure that we split that property out and write it on its own line.
Otherwise, in such cases, once again we found that in some cases we are better off using getPropValue
const title
= getPropValue(obj, 'main.content.title') || 'default title'
Conclusion
Phew! A quick not-so-quick summary of some of the things we’ve learned through our use of object destructuring over the last couple of years.
To sum up, when we do use object destructuring, we tend to use pattern 4 (repeated below for reference) whenever possible when we know the object we are destructing from has the correct shape. If we can’t be sure of that, then we sprinkle in some defaults as in pattern 1.
One thing to bear in mind is that, as with most any coding paradigm, destructuring is not the best pattern to use in all cases. For destructuring statements that are more complex and difficult to both write and read, we find that getPropValue
can often achieve the same thing in an easier to read format.
Have a look at the example below. It could be that in this particular case we know for sure that obj
contains a property main
that is never null
or undefined
when we come to destructure it. But let’s say that we can’t be sure that main
contains the content
property so we assign a default to it inline:
const defaultObj = {
main: {
content: {
title: 'defaultTitle',
description: 'defaultDescription'
}
}
}const { main: { content: { title } = { title: 'default title' } } }
= obj || defaultObj
This is already beginning to get difficult to read. When we have to destructure a more deeply nested property and set defaults as in the previous example, it can quickly get out of hand. If object destructuring is to be used as syntactic sugar to make the code easier to read, for ourselves now and for future maintainers of the code who may not be as familiar with destructuring, we always have to question whether we are achieving that goal or if there is a better way.
We’ve found that object destructuring isn’t a ‘golden hammer’. When the meaning starts to get lost in a sea of defaults and curly brackets, it’s time to take a step back and consider a different approach, which may mean falling back to using our tried and trusted getPropValue
utility function:
const title
= getPropValue(obj, 'main.content.title') || 'default title'
or even simple dot notation:
const title = obj.main.content.title || 'default title'
This blog post was in part inspired by the ‘learning in public’ philosophy of Kent C.Dodds. Even though we feel we’ve got a lot of things right (otherwise we wouldn’t be doing them in the way we are), we are always open to and welcome comments on techniques we could improve upon, features we might have missed, or simply a better way of doing things. So please, don’t feel shy. Drop a comment below and let’s all learn together.
Bernard Leech is a Javascript Developer at Crunch. Bernard has a background in electronic engineering, before becoming a Javascript Developer. When not developing, Bernard enjoys writing EDM music using Propellerhead products.
Find out more about the Technology team at Crunch and our current opportunities here.