Seven Common JavaScript Idioms That Quietly Introduce Future Bugs
Explains how everyday JavaScript patterns like truthy checks, optional chaining, and spread syntax hide assumptions that break silently as code evolves.
This article is published in English.
A pull request trims twelve lines of JavaScript down to four. The updated version leans on optional chaining, a truthy check, object spread, and a tidy lookup table, so the review goes smoothly. The change looks tighter, reads naturally, and behaves correctly for every input the application currently sees.
Then, months later, 0 starts being accepted as a legitimate configuration value. An API response begins leaving out a field that used to always be present. A background job that once handled dozens of records suddenly has to process thousands. A nested object gets reused somewhere that assumed it had already been copied. None of these later events look connected to the earlier refactor, yet each one exposes an assumption that the compact code had baked in without saying so.
This is exactly why certain JavaScript idioms are risky. They rarely look wrong at the time, and skilled developers reach for them precisely because they're convenient, expressive, and accurate under the requirements that exist right now. Trouble starts when that convenience quietly merges several distinct states into a single behavior, or when syntax that reads like a safety guarantee is really just shorthand.
So the real question during review isn't whether a piece of code is clever or terse. It's whether the code exposes its assumptions clearly enough that future changes will cause a loud failure rather than a silently wrong result.
1. Truthy Checks Collapse Different Values Into the Same Decision
Imagine a config setting that controls how many times an operation is allowed to retry.
function getRetryCount(config) {
if (!config.retries) {
return 3;
}
return config.retries;
}
This logic is fine as long as retries is either a positive number or absent. But once someone adds the ability to disable retries entirely by setting the value to 0, the function silently overrides that explicit choice and returns 3 instead. JavaScript's truthiness rules are being followed correctly — it's just that those rules no longer align with what the business actually needs.
Several values other than false evaluate as falsy in JavaScript: 0, "", null, undefined, and NaN all qualify. So a condition like if (!value) isn't really testing whether something is missing — it's testing whether JavaScript would coerce it to false, which is a considerably broader question than "is this absent?"
A more accurate version depends on what the actual contract is.
function getRetryCount(config) {
if (config.retries === undefined) {
return 3;
}
return config.retries;
}
And when both null and undefined are meant to signal "nothing was supplied," nullish coalescing may express the intent more cleanly.
const retries = config.retries ?? 3;
This distinction matters a great deal for form inputs, pagination parameters, prices, limits, feature flags, counters, and other numeric settings. A flag named enabled can rightfully be false. A search field can rightfully be an empty string. An offset can rightfully be 0. Treating every falsy value as "not provided" throws away information in exchange for brevity.
None of this makes truthy checks inherently wrong. They work well when the requirement genuinely is about truthiness — for instance, deciding whether to render an optional string only if it's non-empty. They become a liability once the domain has more distinct states than JavaScript's own boolean coercion can preserve.
The question worth asking in review is straightforward: does this condition actually need to test for falsiness, or is truthiness being used as a stand-in for a more specific business rule?
2. Optional Chaining Can Turn Broken Data Into Quietly Missing Data
Optional chaining ranks among JavaScript's more valuable additions because it makes navigating deeply optional structures far less painful.
const avatarUrl = user.profile?.avatar?.url;
When profile or avatar really can be absent, this is precisely the situation optional chaining exists for. Getting back undefined is meaningful here, since absence is a normal, expected outcome.
The trouble starts when optional chaining gets applied to data that's supposed to always be there.
function getShippingCountry(order) {
return order.customer?.address?.country;
}
Suppose the system's contract guarantees that any shippable order includes a customer and a shipping address. If an upstream bug ever produces an order missing one of those, optional chaining quietly converts what should be a contract violation into an ordinary undefined. That issue might not surface until much later, showing up as a wrong tax calculation, a missing shipping label, or a confusing branch somewhere unrelated in the codebase.
A better approach in this case may be to fail immediately, right at the point where the assumption actually matters.
function getShippingCountry(order) {
if (!order.customer?.address) {
throw new Error(
`Order ${order.id} is missing a required shipping address`
);
}
return order.customer.address.country;
}
This version is more verbose, but it communicates more. It tells anyone reading it later that the address isn't just nice-to-have data that may or may not show up — its absence means the object shouldn't be treated as valid for this operation at all.
Optional chaining also has a tendency to spread once uncertainty creeps into a type or an API contract. A single uncertain field turns into foo?.bar, which soon becomes foo?.bar?.baz, and before long every caller is quietly tolerating missing data instead of asking why a field that's supposed to be required keeps disappearing. The syntax cuts down on thrown exceptions, but it can just as easily increase overall ambiguity.
Whether optional chaining is the right tool should come from the domain model, not from convenience. If a value is genuinely optional, optional chaining is a clean way to express that. If it's required, letting undefined propagate further through the program can make the eventual bug much harder to trace back to where it actually started.
3. Spread Syntax Feels Like a Full Copy, but Nested Objects Keep Their Original Identity
Spreading an object into a new one is a compact way to write updates that look immutable.
const updatedUser = {
...user,
name: "Umar",
};
At the top level, this really does build a fresh object with the values you expect. That success tends to make developers read { ...object } as "clone this object," when in reality the operation only duplicates the properties sitting directly on that object.
Anything nested one level deeper doesn't get that treatment.
const original = {
name: "Umar",
preferences: {
theme: "light",
},
};
const copy = { ...original };
copy.preferences.theme = "dark";
console.log(original.preferences.theme);
// "dark"
Here, copy is indeed a distinct outer object, but copy.preferences still points at the exact same object as original.preferences. The spread duplicated the reference, not the structure hanging off of it.
This gets risky fast inside state management, since the resulting code still has the visual shape of an immutable update.
const nextState = { ...state };
nextState.user.preferences.theme = "dark";
The outer object is new, but user and preferences may well be the very same objects from the previous state. Anything that decides whether to re-render or recompute based on reference equality can end up looking at a hybrid: some parts freshly created, some parts mutated in place. Meanwhile, any other piece of code still holding a reference to the old state will silently see the mutation too.
If only one branch of a nested object actually needs to change, copy that branch on purpose rather than relying on a single top-level spread.
const nextState = {
...state,
user: {
...state.user,
preferences: {
...state.user.preferences,
theme: "dark",
},
},
};
It's more to type, but every level that changes gets a new identity, while the parts you didn't touch continue to be shared safely. Tools like Immer can smooth this pattern out once state trees grow deep and nested, but they don't remove the underlying requirement: you still have to know which references survive an update and which ones don't.
Spread syntax itself isn't flawed. The bug comes from assuming a shallow copy buys you deep isolation.
Any time you're spreading an object to shield it from later mutation elsewhere, stop and check which nested references are still shared afterward. That check matters far more than whether the syntax happens to use ....
4. Round-Tripping Through JSON.parse(JSON.stringify(...)) Isn't the Same as Cloning
A go-to trick for sidestepping shallow-copy problems is this one-liner:
const copy = JSON.parse(JSON.stringify(original));
It seems to fix the nested-reference issue, since parsing the JSON string does generate brand-new objects. For data that's already shaped like plain JSON, it can even give you precisely what you were hoping for.
The problem is treating that outcome as a general guarantee of deep cloning.
JSON only understands a limited data model. As soon as your object contains values outside that model, serialization can silently alter them, drop them, or throw.
Take this object as an example:
const value = {
createdAt: new Date(),
missing: undefined,
settings: new Map([["theme", "dark"]]),
};
Once it's been through a JSON round trip, the Date instance is now just a string and has lost all Date-specific behavior. The undefined property vanishes entirely. The Map doesn't survive as a Map with its key-value pairs intact, which is a problem if the caller expected a proper structural clone.
Circular references break things even more directly:
const user = {
name: "Umar",
};
user.self = user;
JSON.stringify(user);
// TypeError
Other kinds of values bring their own headaches. BigInt can't go through ordinary JSON.stringify without extra handling, symbol-keyed properties are simply left out, and instances of custom classes lose the prototype that gave them their class-specific behavior in the first place.
None of this makes JSON serialization unreliable — it's doing exactly what serialization is supposed to do, which is compress JavaScript values down into the narrower shape that JSON supports.
When you genuinely need a structural clone, structuredClone() is frequently a better built-in choice, provided the types you're working with are ones it supports. For data tied closely to your domain, writing an explicit copy function can be even more reliable, since it spells out exactly which properties deserve preservation instead of assuming every property should be cloned generically.
It's also worth noting that deep cloning is often the wrong tool altogether. If you only need to change one nested branch of an object, cloning the whole structure wastes effort and makes it harder to see which part actually changed.
A good habit is to name the operation for what it is. When you mean to serialize something, do that deliberately. When you mean to clone something, pick a cloning approach whose behavior actually matches the values you're storing.
5. Promise.all() Doesn't Cap How Much Runs at Once
Imagine an application that needs to look up details for a batch of users.
const users = await Promise.all(
userIds.map((id) => fetchUser(id))
);
For a handful of users, this works nicely and is generally the right call. The code kicks off all the async calls, waits for them collectively, and hands back results ordered to match the input array.
Later, someone reuses the same function against a dataset of 50,000 records.
const users = await Promise.all(
userIds.map((id) => fetchUser(id))
);
Nothing in the syntax changed, but the scale did. Since .map() invokes fetchUser for every single item, and each call likely fires off a network or database request right away, Promise.all() doesn't impose any ceiling on how many of those requests run simultaneously.
That can flood a downstream service, drain a connection pool, hit rate limits, spike memory usage, or set off a wave of retries once errors start piling up. Visually the code still reads like a tidy batch operation, so it's easy to overlook what it actually costs to run.
There's a second hidden assumption baked into Promise.all(): as soon as one promise rejects, the whole thing rejects. That's the right behavior when you're treating the group as a single unit of work, but it doesn't mean the other in-flight operations get cancelled — they keep running even after the failure surfaces.
When the tasks are actually independent and you want every result regardless of individual failures, Promise.allSettled() is often a better fit for what you're trying to express.
const results = await Promise.allSettled(
userIds.map((id) => fetchUser(id))
);
But if your real worry is load on some other system, switching between Promise.all() and Promise.allSettled() won't fix that on its own — neither one throttles concurrency. What you actually need is batching, a semaphore, a queue, or some concurrency-limiting utility that keeps only a fixed number of operations active at a time.
This pattern is completely fine when the input set stays small and predictable and the target system can absorb the parallel load. The bug shows up later, when logic written with ten records in mind gets applied to ten thousand and nobody stops to reconsider whether that's still safe.
Concurrency isn't a function of how tersely you await your promises — it's a function of how much work you launch and what the underlying systems can actually handle.
6. Functional-Looking Array Methods Can Still Mutate
Array methods in JavaScript invite compact, readable data pipelines, and most of the time that's a genuine win.
const activeUsers = users
.filter((user) => user.active)
.map((user) => user.name);
Because filter() and map() both hand back fresh arrays, it's tempting to generalize that any chain of array methods leaves the original untouched. That assumption falls apart the moment sort(), reverse(), splice(), or a few other mutating methods join the chain.
Picture a React component that receives a users array as a prop:
function UserList({ users }) {
const sortedUsers = users.sort(
(a, b) => a.name.localeCompare(b.name)
);
return sortedUsers.map((user) => (
<User key={user.id} user={user} />
));
}
It looks like this code produces a new sortedUsers array, but sort() actually rearranges the original array in place and just returns a reference to that same array. So a component whose only job is rendering has, as a side effect, altered data that belongs to its parent.
That kind of mutation can stay hidden for a long time, until some other part of the app relies on the original order. Tests for this component keep passing, because the rendered output looks correct, while a sibling component or a memoized value elsewhere quietly starts working with reordered data it never expected.
One fix is to clone the array before applying a mutating method.
const sortedUsers = [...users].sort(
(a, b) => a.name.localeCompare(b.name)
);
Newer JavaScript runtimes also offer non-mutating equivalents, like toSorted(), if your target environment supports them.
The real lesson here isn't to memorize which array methods are "safe." It's to think about whether an operation respects who owns the data.
If a function is handed a value it doesn't own, silently mutating it introduces behavior that has nothing to do with what the function is supposed to be responsible for. Code stays far easier to reason about when mutation either doesn't happen at all, or happens explicitly, at the place that actually owns the value.
7. Lookup Tables Can Bury Invalid Input Instead of Rejecting It
It's common to swap a switch statement for a plain object used as a dispatch table, since it reads as more declarative.
const handlers = {
create: createUser,
update: updateUser,
delete: deleteUser,
};
handlers[action]?.(payload);
This is compact, and dispatch-by-lookup is a genuinely good pattern in many cases. What's easy to overlook is what happens when action doesn't match any known key.
Because this example leans on optional chaining, an unrecognized action just does nothing, silently.
action = "udpate"; // typo
handlers[action]?.(payload); // no handler runs
A simple typo can now flow through the system disguised as a successful no-op. Unless the caller checks for some meaningful return value, the rest of the application may report that the operation succeeded even though nothing actually ran.
Failing loudly makes the contract far more honest.
const handler = handlers[action];
if (!handler) {
throw new Error(`Unsupported action: ${action}`);
}
return handler(payload);
Lookup tables get riskier once the keys originate from user input or loosely validated sources. Plain objects also inherit properties from their prototype unless you go out of your way to prevent that, so treating arbitrary strings as safe property lookups deserves more caution than it would for a small, closed set of internally known commands.
Using a Map instead can make the dictionary-style intent clearer:
const handlers = new Map([
["create", createUser],
["update", updateUser],
["delete", deleteUser],
]);
const handler = handlers.get(action);
if (!handler) {
throw new Error(`Unsupported action: ${action}`);
}
return handler(payload);
In TypeScript, pairing a discriminated union with an exhaustive switch can pay off even more, since introducing a new action then forces the compiler to flag every spot in the codebase that needs updating.
None of this means switch is categorically better than a lookup table. Dispatch tables shine when operations are naturally data-driven and the behavior for unknown keys has been deliberately decided.
The trouble starts when indirection makes it impossible to tell "the program deliberately handled this" apart from "nothing happened at all."
The Real Cost of Cleverness Is Hidden Assumptions
None of these seven habits deserves an outright ban.
Truthy checks work well when truthiness genuinely lines up with your domain rules. Optional chaining shines for relationships that are truly optional. Object spread is a great fit for shallow updates. JSON is the natural format for JSON-shaped data. Promise.all() is perfectly suited to bounded parallel work. Mutating array methods are fine when you own the array outright. And lookup tables can make dispatch logic wonderfully simple.
The risk is treating their convenient defaults as guarantees the syntax was never actually making. !value doesn't mean "missing." ?. doesn't mean "safely present and valid." { ...obj } doesn't mean "fully independent copy." Serializing to JSON and back doesn't mean "generic clone." And Promise.all() doesn't mean "controlled concurrency."
That's why the most maintainable version of a piece of JavaScript isn't always the shortest one. A few extra lines that distinguish undefined from 0, reject a malformed object shape, cap parallel requests, or throw on an unrecognized action can preserve information that future maintainers would otherwise have to dig out of commit history.
Whenever code strikes you as unusually clever, take a moment to look at what got compressed away. Sometimes the answer is that only unnecessary ceremony was removed while the meaning stayed intact — which is exactly what good abstraction is supposed to do. Other times, what got compressed was a distinction the current requirements simply haven't needed yet.
It's that second case that tends to seed tomorrow's bugs.
A useful habit during code review is to ask what condition has to remain true for a terse pattern to keep working once requirements shift. If that condition matters but isn't visible anywhere in the code, write it down explicitly while the logic is still fresh and easy to follow.
Future maintainers rarely run into trouble because JavaScript let them write too much code. They run into trouble because the code quietly stopped telling them which distinctions actually mattered.