Modern JavaScript Syntax: Destructuring, Spread/Rest, Optional Chaining and Logical Assignment
We learn array and object destructuring, the difference between spread and rest, shorthand and computed properties, ?. with ??, and the ||=, &&= and ??= operators, along with the ES edition each came from.
Version note: The code in this post targets ES2026 and current browsers (Chrome, Edge, Firefox, Safari).
Predict First
In the Fundamentals series we covered the building blocks of the language and the browser, from variables and functions to objects and arrays, from the DOM and events to forms, the BOM, Date and RegEx. The Intermediate series builds on that foundation with modern syntax, the deeper parts of the language such as scope, closures and this, classes, asynchronous programming and the browser's more advanced APIs. First stop: the shorthand syntax you will run into in almost every JavaScript file you open today.
To warm up, look at these three lines:
const { a = 1, b = 2 } = { a: undefined, b: null };
console.log(a);
console.log(b);
What does this print to the console? Make a guess before you read on. We will give the answer once we have covered object destructuring.
Array Destructuring: Unpacking a Box
Picture a moving box. You take things out one by one and put each on its own shelf. That is exactly what destructuring is: spreading the values inside an array or an object into separate variables in a single line. Before this syntax arrived in ES2015, we did the same job one index at a time.
Paste this into your browser console and try it:
const colors = ["red", "green", "blue"];
// The old way
const firstColor = colors[0];
const secondColor = colors[1];
// Destructuring
const [red, green, blue] = colors;
console.log(red, green, blue); // red green blue
The square brackets on the left do not create a new array here. They say "bind the elements of the array on the right to these names, in order." The engine looks at the right-hand side and hands the first element to the first name, the second to the second. If the array is missing an element, the matching variable becomes undefined; if there are extra elements, they are quietly ignored.
To skip an element, leave its slot empty with a comma. Swapping two variables without a temporary one is also one of destructuring's favorite tricks:
const [, , third] = ["a", "b", "c"];
console.log(third); // "c"
let x = 1;
let y = 2;
[x, y] = [y, x];
console.log(x, y); // 2 1
Object Destructuring: Defaults and Renaming
With objects, names matter, not order. The name you write inside the curly braces is matched against the property name in the object:
const user = { firstName: "Ada", city: "London", age: 31 };
const { firstName, city } = user;
console.log(firstName, city); // Ada London
If you want the variable to have a different name, use a colon: { city: town } means "take the city property and put it in a variable called town." An equals sign gives a default value. You can combine the two:
const { city: town, country = "UK", age: years = 18 } = user;
console.log(town, country, years); // London UK 31
Now back to the opening question. The answer is 1 and null. A default only kicks in when the matching value is exactly undefined. Since a was undefined, the default 1 was used. b, on the other hand, is null, a value that was left empty on purpose; as far as the engine is concerned there is a value there, so no default is needed. The same rule applies to the default parameters we saw earlier.
Destructuring really shines in function parameters. Instead of passing a function with many options a list of positional arguments, you pass a single object, and the function picks out what it needs right in its parameter list:
function createButton({ label, color = "gray", size = "md" } = {}) {
return `${label} (${color}, ${size})`;
}
console.log(createButton({ label: "Save", color: "green" })); // Save (green, md)
console.log(createButton()); // undefined (gray, md)
The = {} at the end is a small but important detail. If the function is called with no argument, it tries to unpack undefined and throws; the empty object default prevents that. As a bonus, nobody has to memorize the argument order anymore. You can pull values out of nested objects the same way, for example const { address: { zip } } = order;. But once you go more than two levels deep, readability drops fast, so don't overdo it.
Spread and Rest: Same Three Dots, Two Different Jobs
We used three dots (...) earlier to copy arrays and objects. Depending on where they appear, those dots do two opposite things. Where a value is expected, they are spread and scatter the elements out. Where a variable or parameter is being declared, they are rest and gather up whatever is left. Think of a bag: spread empties the bag onto the table, rest sweeps what is left on the table back into the bag.
// Spread: unpack and scatter
const base = [1, 2, 3];
const more = [0, ...base, 4]; // [0, 1, 2, 3, 4]
console.log(Math.max(...base)); // 3
const defaults = { theme: "light", lang: "en" };
const prefs = { ...defaults, theme: "dark" };
console.log(prefs); // { theme: "dark", lang: "en" }
// Rest: gather
const [head, ...tail] = [10, 20, 30]; // head: 10, tail: [20, 30]
const { theme, ...others } = prefs; // theme: "dark", others: { lang: "en" }
function sum(...numbers) {
let total = 0;
for (const n of numbers) total += n;
return total;
}
console.log(sum(1, 2, 3, 4)); // 10
Order matters with object spread: if two properties share a name, the one written last wins. That is why you write the defaults first and the user's choices after them. Spread and rest for arrays arrived in ES2015; the same syntax for objects was added in ES2018.
A rest parameter must always come last, and a function can have only one. It is the modern replacement for the arguments object you see in older code, and it is a real array on top of that. One more thing to keep in mind: spread makes a shallow copy. When you copy a nested object, the inner object stays shared between the two copies; for a deep copy there is structuredClone, which we saw earlier.
Shorthands: Properties, Methods and Computed Properties
When you build an object and the variable name matches the property name, you don't have to write both. Methods can drop the function keyword too. With square brackets, you can compute a property name at runtime; this is called a computed property. All three are gifts from ES2015:
const title = "Keyboard";
const price = 125;
const field = "stock";
const product = {
title, // title: title
price, // price: price
[field]: 40, // stock: 40
[`${field}Alert`]: 5, // stockAlert: 5
describe() { // describe: function () { ... }
return `${this.title} - ${this.price} USD`;
}
};
console.log(product.stock, product.stockAlert); // 40 5
console.log(product.describe()); // Keyboard - 125 USD
Computed properties are especially handy when you don't know the key name ahead of time. When turning a form field into an object, writing { [input.name]: input.value } produces the right key no matter which input comes in. We are only winking at the this inside the method here; we will see exactly how it is decided in a later post.
Optional Chaining and ??: Safe Access
When working with nested data, the error you see most often is probably Cannot read properties of undefined. If a link in the middle of the chain is missing, you can't reach anything after it. Optional chaining (?.), which arrived in ES2020, asks at every step: "is there something here?" If not, instead of throwing, it returns undefined and skips the rest of the chain entirely. This is called short-circuiting.
const order = {
id: 7,
customer: { name: "Grace", address: null },
items: [{ title: "Book" }]
};
console.log(order.customer.address?.city); // undefined
console.log(order.items?.[0]?.title); // "Book"
console.log(order.coupon?.code); // undefined
console.log(order.customer.notify?.()); // undefined, no such function
It comes in three forms: ?. for properties, ?.[] for bracket access and ?.() for calling a function. Add the ?? operator we saw earlier at the end, and safe access plus a default value fit on one line. ?? also arrived in the same year, with ES2020:
const shipCity = order.customer.address?.city ?? "No address";
console.log(shipCity); // "No address"
A word of warning: sprinkling ?. after every dot hides bugs. Use it when it is normal for a piece of data to be missing sometimes. If a missing value means something went wrong, leave the chain plain so you see the problem early, right where it happens.
Logical Assignment Operators
The ||=, &&= and ??= operators, which arrived in ES2021, shorten the "if there's no value, assign this" pattern we write all the time. Each one follows the short-circuit rule of its matching operator:
const config = { retries: 0, label: "", timeout: undefined, token: "abc" };
config.retries ||= 3; // 0 is falsy, becomes 3
config.label ??= "Untitled"; // "" is not null/undefined, stays as is
config.timeout ??= 5000; // undefined, becomes 5000
config.token &&= config.token.toUpperCase(); // transform only if present: "ABC"
console.log(config);
// { retries: 3, label: "", timeout: 5000, token: "ABC" }
a ||= b is the same as a || (a = b), not a = a || b. The difference is subtle but real: if the left side is already truthy, no assignment happens at all. That matters when the assignment itself does something, for example when it changes a DOM property. Which one to pick depends on what you consider "empty." If 0, "" and false are valid values for you, use ??=; it only reacts to null and undefined.
Which Feature Came with Which Edition?
Everything in this post works in all current browsers today, but knowing where a feature comes from helps when you run into it in an old codebase or an old article. Array and object destructuring, default values, array spread, rest parameters, property and method shorthand and computed properties all came with ES2015. Rest and spread for objects were added in ES2018. Optional chaining and ?? are from ES2020, and the logical assignment operators from ES2021.
As you can see, most of modern syntax arrived in ES2015, and the following years added small pieces that we use every day. ES2026 has no new syntax; its additions are new functions and methods such as Math.sumPrecise and Map.prototype.getOrInsert. We will get to those as they come up.
Common Pitfalls
First, assigning with a curly brace at the start of a line. When you assign to variables you declared earlier using object destructuring and the line starts with {, the engine thinks it is a code block and throws a SyntaxError. The fix is to wrap the whole expression in parentheses:
let width, height;
({ width, height } = { width: 800, height: 600 });
console.log(width, height); // 800 600
Second, unpacking something that isn't there. const { id } = null; throws a TypeError straight away. If the data comes from an API or from the user, make a habit of guarding with an = {} default or ?? {}.
Third, mixing up || and ??. If the user deliberately set the volume to 0, volume || 50 overwrites it with 50. For settings that can be a number, an empty string or a boolean, you almost always want ??. You also can't put the two side by side without parentheses: a || b ?? c is a SyntaxError. The language doesn't want you guessing which runs first, so spell out your intent with parentheses.
Fourth, mistaking spread for a deep copy. In a copy made with { ...order }, customer still points to the same object. Change the customer's name in the copy and the original changes too.
A Small Experiment
★☆☆ Use let and destructuring to unpack the array [3, 7] into px and py. Then swap them in a single line and log them.
★★☆ Predict the output before running it: const { level = "medium", score = 10 } = { level: null, score: 0 }; console.log(level, score); Then think about the same question for null ?? "medium" and 0 || 10.
★★☆ Write a function with the signature function formatUser({ name, email, role = "member" } = {}) that returns a sentence containing the user's name and role. It should not throw when called with no arguments.
★★★ Write two versions of setDefault(obj, key, value). The first changes the object in place with ??=. The second uses spread and a computed property to return a new object without touching anything. Call both with the same object and show in the console which one changes the original.
In this post we took arrays and objects apart and put them back together. Next up is where arrays really get powerful: we will look at array methods in depth, starting with map, filter and reduce. Destructuring and spread will be close at hand there too.