Modern JavaScript syntax — destructuring, spread, optional chaining — is a compression format, and knowing when modern syntax reads worse than plain code is a skill worth building. Each of these features removes a step of naming, and that is usually a good trade. But compression always costs something at the other end: the reader has to expand it again in their head before they can trust it. This lesson is about the cases where the expansion is bigger than the step you removed, and about the practical habits that keep the difference visible.
When does destructuring read worse than plain code?
It reads worst when the pattern nests more than one level deep and renames as it goes. The reader has to hold the whole shape in their head before they can predict what a single letter stands for.
const render = ({ user: { name: n, address: { city: c = 'Unknown' } = {} } = {} }) =>
`${n ?? 'Guest'} — ${c}`;
function renderPlain(payload) {
const user = payload?.user ?? {};
const address = user.address ?? {};
const name = user.name ?? 'Guest';
const city = address.city ?? 'Unknown';
return `${name} — ${city}`;
}
console.log(render({ user: { name: 'Ada', address: { city: 'London' } } }));
console.log(renderPlain({ user: { name: 'Ada', address: { city: 'London' } } }));
Both versions print the same thing, and the second one is longer on the screen. But look at what the first version is doing: four levels of pattern, two renames down to single letters, a default in the middle of the pattern and another at the end. Every one of those is a rule the reader has to hold in mind. The plain version states the same four decisions one per line, and each line has a name that says what it is.
There is also a correctness difference hidden in the first version. The defaults it uses apply only to undefined, so passing { user: null } throws a TypeError instead of falling back — while the plain version, which uses ??, treats null and missing the same way. The clever form is not merely shorter; it is quietly stricter, and that is the kind of difference you want spelled out rather than compressed.
When does a method chain read worse than plain code?
A chain of two or three links is usually a win. The point where it stops being one is when the intermediate shape starts to matter — because with a chain, the reader has to infer the shape of each intermediate array, and those shapes are not written down anywhere.
const orders = [
{ id: 1, customer: 'Ada', total: 120, paid: true },
{ id: 2, customer: 'Grace', total: 80, paid: false },
{ id: 3, customer: 'Alan', total: 200, paid: true }
];
const names = orders.filter((o) => o.paid).map((o) => o.customer).join(', ');
const paidOrders = orders.filter((o) => o.paid);
const customers = paidOrders.map((o) => o.customer);
const summary = customers.join(', ');
console.log(names);
console.log(summary);
The first version is shorter and perfectly correct. The second version is the same three operations with the two intermediate arrays given names, and the names answer the questions a reader actually has: what am I filtering for, and what does one item look like at this point? That second question is the one a chain refuses to answer — the reader has to hold the original record shape in their head and mentally apply the transformation.
There is a second cost that is not about readability. Every link produces a new array, so a four-link chain allocates three intermediate arrays that nothing else ever sees. A single loop avoids that. That is a statement about how many objects get created, not a claim about how fast anything runs — measure it if the list is genuinely large.
When does optional chaining read worse than plain code?
Optional chaining is at its worst when it is used to paper over a shape you do not trust. Each question mark removes a crash, and a run of them removes a lot of information about what went wrong.
const config = { features: { export: { csv: { enabled: true } } } };
console.log(config?.features?.export?.csv?.enabled ?? false);
console.log(config?.features?.exports?.csv?.enabled ?? false);
console.log(config?.features?.export?.csv?.enabeld ?? false);
Three lines, one correct answer and two silent false values. The second line has a typo in a key name and the third has a typo in a property name, and neither of them complains. The chain says: whatever happens down there, the answer is false. That is exactly what you want for a flag that a user may legitimately turn off, and exactly what you do not want for a setting where a typo should be caught in testing rather than in production.
Notice also that the chain says nothing about the shape of the configuration. If config.features might be missing, the honest place to deal with that is once, where the configuration is loaded, not on every line that reads it.
When does the spread operator read worse than plain code?
Spread inside a loop is the case to watch. It copies on every iteration, and it copies a shape the reader has to work out rather than a shape they were told about.
const rows = [{ id: 1, tag: 'a' }, { id: 2, tag: 'b' }];
const tagged = [];
for (const row of rows) {
tagged.push({ ...row, tag: row.tag.toUpperCase() });
}
console.log(tagged);
console.log(rows[0]);
The copy is harmless at this size and the result is correct: the original rows are untouched, which is the point of copying rather than mutating. The readability cost is that the object literal defines a new shape — id from the old row, tag replaced — and nothing says that it differs from the shape in rows. Inside a loop, name the new shape, because the reader is looking at two similar objects in the same file and needs to be told which is which.
Two other spread habits are worth questioning. Copying with a spread when you only need to read is pure overhead — the copy protects nothing if nothing is modified. And spreading a large array into a function call turns every element into a real argument, which has a limit, so the same task written as a loop behaves differently once the list grows.
So when does modern syntax earn its place?
| Modern syntax | Reads well when | Reads badly when |
|---|---|---|
| destructuring | one level deep, with the same names, in a function parameter | nested more than one level, or renaming to single letters |
| method chains | two or three links, and the intermediate shape is obvious | four or more links, or the shape changes mid-chain |
| optional chaining | the value really is often absent, such as a nickname | it is hiding a required value or a typo |
| nullish coalescing | zero and the empty string are meaningful values | any falsy value should have triggered the fallback |
| spread | building a new collection from existing pieces | inside a loop, or when nothing is modified afterwards |
The pattern across all of them is the same. Modern syntax is a good deal when it removes a step the reader did not need — a temporary variable, a defensive check, a repeated property path. It is a bad deal when it removes a step the reader did need, such as the name of an intermediate value, the shape of an object, or the fact that a value was missing.
A readability checklist worth keeping
- If a line needs a comment to explain it, rewrite the line. A comment is a sign the code is doing something the reader cannot see.
- Name things for what they hold.
displayNamebeatsn, even in a short function. - Apply a default once, near the boundary. Fix the shape where the data enters, not with a fallback on every line.
- Keep chains to two or three links. Past that, name the intermediate values.
- Do not guard what must exist. A missing required value is a fault, and faults should be loud.
- Copy only when you will change something. A copy that is never modified protects nothing and costs time.
None of this is an argument against the modern syntax. It is an argument for using it where it pays. The syntax is a tool, and the measure of a tool is not how clever it looks but how much work it leaves for the next person who opens the file — including you, in six months, on a bad day.
Try it yourself: take a single function and rewrite it twice. Start with this one-liner, which filters paid orders above a threshold and formats them: const format = (orders) => orders.filter((o) => o.paid && o.total > 100).map((o) => `${o.customer}: ${o.total}`).sort();. Write it out with named intermediate variables, then write it again as a single loop that filters and formats in one pass. Test all three versions against the same five orders — including one unpaid order, one paid order under the threshold, and two that qualify — and check that the three outputs are identical strings. Then make a decision and write it in a comment at the top of the file: which version you would rather debug at six in the evening, and why. Keep that comment; it is the whole lesson.