Three dots syntax (...
) became quite popular in JavaScript world in the last years. It's used for a couple of different things: object and array spread, destructuring and rest arguments. In each case, the same part keeps tricky or at least not quite intuitive - empty values. What if you want to spread an array that appears to be undefined
? What about destructuring a null
object?
Object spread
const foo = { ...bar, baz: 1 };
Spreading an object is quite a common pattern when you want to create one object based on another. In the above example, we're creating object foo
by taking all the properties of bar
, whatever it contains, and setting one particular property baz
to 1
. What if bar
turns out to be undefined
or null
?
const bar = undefined;
const foo = { ...bar, baz: 1 };
console.log(foo);
{ baz: 1 }
The answer is: nothing bad happens. The JavaScript engine handles this case and gracefully omits a spread. The same goes for null
, you can check it by yourself. That was easy!
Object destructuring
const { baz, ...bar } = foo;
Destructuring an object is handy when dealing with nested data structures. It allows binding property values to names in the scope of the function or the current block. In the example above two constant values are created: baz
equal to the value of foo.baz
and bar
containing all other properties of the object foo
(that's what is called "a rest"). What happens when foo
is an empty value?
const foo = undefined;
const { baz, ...bar } = foo;
console.log(baz, bar);
Uncaught TypeError: Cannot destructure property 'baz' of 'foo' as it is undefined.
In this case, the JavaScript engine gives up and throws a TypeError
. The issue here is, that non-object value (and everything except null
and undefined
is an object in JavaScript), simply cannot be destructured. The issue can be resolved by adding some fallback value to the statement, so the destructuring part (the left one) always gets an object.
const { baz, ...bar } = foo || {};
This kind of error usually occurs when destructuring function arguments or nested objects. In such a case, instead of ||
operator, we can use a default parameter syntax. A caveat here is not handling the null
value. Only undefined
will be replaced with an empty object.
function foo({
baz: {
qux,
...bar
} = {}
} = {}) {
// ...
}
Array spread
const foo = [ baz, ...bar ];
Similarly to the object, we can create an array based on the other. At first sight, the difference is only about the brackets. But when it comes to empty values...
const bar = undefined;
const foo = [ ...bar, 1 ];
console.log(foo);
Uncaught TypeError: undefined is not iterable (cannot read property Symbol(Symbol.iterator))
Unlike object spread, the array spread doesn't work for null
and undefined
values. It requires anything iterable, like a string, Map
or, well, an array. Providing such a value as a fallback is enough to fix the issue.
const foo = [ ...(bar || []), 1 ];
Array destructuring
const [ baz, ...bar ] = foo;
Array destructuring is no different - the destructured value must be iterable.
const bar = undefined;
const [ baz, ...bar ] = foo;
console.log(baz, bar);
Uncaught TypeError: foo is not iterable
Again, the remedy may be ||
operator or the default argument value when it's about destructuring function parameters.
const [ baz, ...bar ] = foo || [];
function foo([
[
baz,
...bar
] = []
] = []) {
// ...
}
To sum up - when it comes to destructuring things, we have to ensure there is always something to destructure, at least an empty object or array. Values like null
and undefined
are not welcome.
Rest arguments
function foo(bar, ...baz) { return [bar, baz]; }
In JavaScript, ...
may be found in one more place - a function definition. In this context, it means: whatever comes to the function after named arguments, put it into an array. In the above example, bar
is a named argument of the foo
function and baz
is an array containing all the rest of values.
What happens when exactly one argument comes to the function or when it's called with no parameters? Is that an issue at all?
foo(1);
[1, []]
It is not! JavaScript engine always creates an array for the rest arguments. It also means that you can safely destructure this value without providing a fallback. The code below is perfectly valid and it's not going to fail even when foo
is called without arguments.
function foo(...bar) {
const [baz, ...qux] = bar;
}
Extra - JSX property spread
const foo = <div {...bar} baz={1} />;
JSX is not even a JavaScript, but it shares most of its semantics. When it comes to spreading the object on the React element, empty values behave just like for object spread. Why is that so?
The code above means: create <div>
element with a single property baz
equal to 1
and all the properties of the object bar
, whatever it contains. Does it sound familiar? Yes! It's nothing more than an object spread.
const fooProps = { ...bar, baz: 1 };
When compiling JSX down to JavaScript, Babel uses old-fashioned Object.assign
function and does not create an intermediate variable, but the final effect is the same.
const foo = React.createElement("div", Object.assign({ baz: 1 }, bar));
So the answer is: null
and undefined
values are just fine when spreading on a React element. We don't need any checking or fallback values.
The snippet
You may wonder what is the result of calling a function presented on the cover photo of this article.
function foo({ bar, ...baz }, ...qux) {
const [quux, ...quuux] = bar;
return [{ qux, ...quux }, ...quuux];
}
foo(undefined);
It fails immediately on destructuring the first argument, as object destructuring requires at least an empty object. We can patch the function adding a default value for the argument.
function foo({ bar, ...baz } = {}, ...qux) {
Now it fails on destructuring of bar
as it's undefined
by default and that's not an iterable thing for sure. Again, specifying a default value helps.
function foo({ bar = [], ...baz } = {}, ...qux) {
In this form, the function works perfectly for undefined
. What about null
? Unfortunately, providing a fallback to both null
and undefined
requires at least ||
operator. The function definition becomes far less concise.
function foo(barBaz, ...qux) {
const { bar, ...baz } = barBaz || {};
const [quux, ...quuux] = bar || [];
return [{ qux, ...quux }, ...quuux];
}
And that's fine only when you don't care about other falsy values like an empty string or 0
. A more safe solution would be a ternary expression like barBaz == null ? {} : barBaz
. Things turn complicated.
Conclusion
Be careful when using three dots syntax with values that you're not sure about, like ones that come from backend API or third party libraries. If you're about destructuring an object or array (or spreading an array), always check against null
and undefined
and provide a fallback value.
In many cases, using optional chaining syntax may produce much more readable code. Check out the performance of this syntax here.