DEV Community

Cover image for A spread, a rest and empty values
Eryk Napierała
Eryk Napierała

Posted on • Updated on

A spread, a rest and empty values

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.

Top comments (0)