Practical Advanced Patterns
Most of what separates readable JavaScript from a tangle of nested if statements is a handful of small, repeatable idioms. None of them are clever for the sake of it — they reduce branching, kill undefined crashes, and make intent obvious at a glance. This page is a grab-bag of the patterns experienced developers reach for daily: guard clauses, lookup maps, nullish defaults, safe deep access, short-circuit assignment, and a few array/object tricks. Every one is plain ES2020+ and runs in both modern browsers and Node.
Guard clauses over nested conditionals
A guard clause exits a function early when a precondition fails, instead of wrapping the happy path in ever-deeper if blocks. The result is flat code where the main logic lives at the top indentation level and edge cases are dealt with and dismissed up front.
// Nested — hard to scan
function getDiscount(user) {
if (user) {
if (user.isActive) {
if (user.plan === "pro") {
return 0.2;
}
}
}
return 0;
}
// Guard clauses — flat and obvious
function getDiscount(user) {
if (!user) return 0;
if (!user.isActive) return 0;
if (user.plan !== "pro") return 0;
return 0.2;
}
The rewritten version reads as a list of “reasons to bail,” then the answer. There is no else to track and no rightward drift.
Lookup maps instead of switch
Long switch statements and if/else if chains that map a value to a result are usually better expressed as an object literal. The map is data, not control flow, so it is easier to extend, test, and even load from elsewhere.
const ROLE_LIMITS = {
free: 5,
pro: 100,
enterprise: Infinity,
};
function uploadLimit(role) {
return ROLE_LIMITS[role] ?? 0; // fallback for unknown roles
}
console.log(uploadLimit("pro"));
console.log(uploadLimit("ghost"));
Output:
100
0
When each case needs behaviour rather than a value, store functions in the map and call the one you pick:
const handlers = {
add: (a, b) => a + b,
sub: (a, b) => a - b,
mul: (a, b) => a * b,
};
const run = (op, a, b) => (handlers[op] ?? (() => NaN))(a, b);
console.log(run("mul", 6, 7)); // 42
Use
??(not||) for the fallback when a legitimate value could be0,"", orfalse. With||,uploadLimitwould wrongly treat a real limit of0as “missing.”
Nullish defaults and short-circuit assignment
The nullish coalescing operator ?? returns its right side only when the left is null or undefined. Its assignment form ??= writes a default only if the slot is currently nullish — perfect for lazy initialisation and config merging.
function createCache(options = {}) {
options.ttl ??= 60_000; // keep an explicit 0 if passed
options.max ??= 100;
return options;
}
console.log(createCache({ ttl: 0 }));
Output:
{ ttl: 0, max: 100 }
The related ||= and &&= operators assign based on truthiness instead. Here is how the three differ:
| Operator | Assigns when left side is | Typical use |
|---|---|---|
??= | null or undefined | Defaults that must allow 0/""/false |
||= | falsy (0, "", false, NaN, null, undefined) | “Use this if nothing meaningful is set” |
&&= | truthy | Update a value only if one already exists |
Safe deep access
Optional chaining ?. short-circuits to undefined the moment any link in the chain is nullish, so you never throw “Cannot read properties of undefined.” It works on property access, array indexing, and calls.
const res = {
data: { user: { addresses: [{ city: "Berlin" }] } },
};
const city = res?.data?.user?.addresses?.[0]?.city ?? "unknown";
console.log(city); // Berlin
// Optional call: only invoke if the callback exists
function notify(onDone) {
onDone?.("finished");
}
notify(); // no error, simply does nothing
Combine it with ?? to read deep and supply a default in one line. Avoid over-chaining values you know exist — ?. everywhere hides genuine bugs by silencing them.
Array and object tricks
Destructuring with defaults, rest, and spread removes a lot of boilerplate. A few that earn their keep:
// Swap without a temp variable
let [a, b] = [1, 2];
[a, b] = [b, a]; // a=2, b=1
// Pull a field out and keep the rest
const user = { id: 1, password: "x", name: "Ada" };
const { password, ...safeUser } = user;
console.log(safeUser); // { id: 1, name: 'Ada' }
// Dedupe with a Set
const unique = [...new Set([1, 1, 2, 3, 3])]; // [1, 2, 3]
// Conditionally include a property
const debug = true;
const config = { port: 3000, ...(debug && { verbose: true }) };
console.log(config); // { port: 3000, verbose: true }
The last idiom relies on object spread ignoring false — when debug is falsy the expression is false and contributes nothing, so the key only appears when you want it.
For grouping or counting, Object.groupBy (ES2024, available in current Node and browsers) replaces manual reduce loops:
const items = [
{ type: "fruit", name: "apple" },
{ type: "veg", name: "kale" },
{ type: "fruit", name: "pear" },
];
const byType = Object.groupBy(items, (i) => i.type);
console.log(Object.keys(byType)); // ['fruit', 'veg']
Best Practices
- Reach for guard clauses first; reserve
elsefor genuinely balanced branches. - Model value-to-value and value-to-handler mappings as objects, not
switchstatements. - Default with
??/??=when0,"", orfalseare valid inputs; use||only for truthiness checks. - Use
?.to tolerate genuinely optional data — never to paper over a shape you control. - Prefer immutable transforms (
...spread,map,filter) over in-place mutation when sharing data. - Keep each idiom doing one thing; chaining five tricks into one line trades clarity for brevity.