Ten Everyday JavaScript Patterns and the Trade-Offs Behind Each One
Guard clauses, lookup maps, result objects, AbortController, allSettled, factories and small caches: when each pattern pays off and when it backfires.
This article is published in English.
Sources: I Reuse These 10 JavaScript Patterns in Almost Every Project
Whether a codebase is a React dashboard, a Node.js API or a small automation script, it eventually runs into the same handful of pressures: functions receive partial data, async responses arrive out of order, conditionals swell around shifting business rules, copies of the same transformation drift apart, and error handling blurs the line between "this failed" and "there was simply nothing to return". Frameworks come and go, but these problems stay remarkably stable. The ten patterns below are deliberately unglamorous answers to them. Each one narrows the set of states a function has to reason about or makes ownership easier to see, and for each you will also learn where it stops helping, because none of them replaces judgment.
1. Guard clauses keep the main path readable
When a function's real work sits inside several nested if blocks, the reader has to hold a stack of open questions: is the input present, is the caller allowed, was the record found, is it in a state that permits this change? Every extra condition adds an indentation level and one more thing to keep in mind.
A guard clause answers each of those questions up front and leaves early when the answer is no. The example below rejects a missing ID, then an unauthorized user, then a missing article, then an article that is not a draft, and only after all four checks does it perform the update.
async function publishArticle(articleId, currentUser) {
if (!articleId) {
throw new Error("Article ID is required");
}
if (!currentUser?.permissions.includes("article:publish")) {
throw new Error("You are not allowed to publish articles");
}
const article = await articleRepository.findById(articleId);
if (!article) {
throw new Error("Article not found");
}
if (article.status !== "draft") {
throw new Error(`Cannot publish an article in ${article.status} status`);
}
return articleRepository.update(articleId, {
status: "published",
});
}
The real gain is not fewer indents; it is growing certainty. Past the first guard, the ID exists. Past the second, the caller has permission. Past the third, article is a real object, so optional chaining does not have to spread through the rest of the body. The final update call can be read with nothing left unresolved. One small edge case worth noticing: currentUser?.permissions.includes(...) still throws a TypeError if a user object arrives without a permissions array, which is exactly the kind of inconsistency the next pattern addresses.
Guards turn into a problem when a long function is peppered with exits for every minor condition. They work best for preconditions, invalid states, missing resources and outcomes that are already complete. If the guards start narrating several distinct business rules, the function is probably making too many decisions and should be split.
2. Normalize data once, at the boundary
JavaScript lets values with the same meaning show up in many shapes. A flag might be true, "true", 1 or "1". An ID can be a number in the database and a string in the URL. A date can be a Date, an ISO string, a locale string or a timestamp. If every function interprets these on its own, the application slowly accumulates several competing definitions of what valid data looks like.
The fix is to convert values once, at the point where they cross a meaningful boundary, before the rest of the code touches them:
- An API handler turns transport-level strings into the types the service layer expects.
- A data adapter maps an external response onto the project's internal shape.
- A form submission handler trims strings and resolves optional fields before business logic runs.
Timing matters. When conversion is postponed, defensive checks spread everywhere: one function tests status === true, another tests status === "true", a third leans on truthiness. Each works for its current caller, yet the meaning of status is no longer stable.
A narrow internal contract also makes tests more focused. When the request layer already hands over a genuine boolean, the service does not need tests for every textual spelling of one. The normalization layer still needs its own tests, but everything behind it gets simpler.
Normalization is not the same as accepting anything. It is appropriate when several representations are intentionally supported; ambiguous or invalid values should still be rejected. Turning "abc" into NaN and letting it flow onward is not normalization, it is validation deferred to some later, more confusing point.
3. Options objects make calls self-describing and easier to extend
Positional parameters are fine for one or two obvious inputs. They become brittle as a function grows, especially when several parameters share a type. A call like createUser(name, email, true, false, 3) sends the reader to the definition to learn what each slot means, and TypeScript types on the declaration do nothing to help at the call site.
Once a function starts accepting several configuration-style values, a single options object is usually clearer:
await createUser({
name,
email,
sendWelcomeEmail: true,
requireVerification: false,
retryLimit: 3,
});
The call now documents itself, optional properties can have defaults (typically through destructuring in the signature), and adding a new option does not force every caller to reshuffle its arguments. Service functions, config helpers, data loaders and evolving utilities benefit most.
It also removes the classic boolean-parameter smell. A bare true in a call almost never says which behavior it switches on; a named property does.
Do not apply it everywhere. add({ a, b }) is not clearer than add(a, b). Reach for an options object when arguments describe configuration, when several are optional, when multiple values share a type, or when the signature is likely to keep changing.
4. Lookup maps turn branching into explicit relationships
Chains of if statements or long switch blocks often appear when one value picks a handler, formatter, validator or strategy. Each new case lengthens the control flow and adds another spot where the default behavior has to be considered.
A lookup map states the relationship directly: this key maps to that behavior. Here an order's status selects its handler, and an unknown status fails loudly instead of falling through silently.
const statusHandlers = {
pending: handlePendingOrder,
paid: handlePaidOrder,
cancelled: handleCancelledOrder,
};
function processOrder(order) {
const handler = statusHandlers[order.status];
if (!handler) {
throw new Error(`Unsupported order status: ${order.status}`);
}
return handler(order);
}
The pattern shines when branches are independent and share one contract: serializers and formatters, event and state handlers, validators, access policies. Supporting a new case means adding one entry rather than extending a conditional.
There is one subtle trap with plain objects. Keys inherited from Object.prototype, such as "toString" or "constructor", also resolve to functions, so an unexpected status value could pick one of those instead of hitting the guard. If keys come from user input, use a Map, an object created with Object.create(null), or an Object.hasOwn check.
Avoid lookup maps when the logic is genuinely sequential. If a decision depends on combinations of state, earlier choices or ordered transitions, flattening it into a map usually hides the flow instead of clarifying it. The map works when one key selects one complete, independently understandable behavior.
Keep substantial logic out of the map itself. Named handler functions produce clearer stack traces, are easier to test in isolation, and let the map stay focused on selection.
5. Result objects separate expected failure from broken execution
Exceptions are the right tool for the unexpected, but plenty of unsuccessful outcomes are perfectly ordinary. A search can find nothing. A sign-up can be rejected because the email is taken. A state transition can be disallowed by current rules. If all of these travel as generic exceptions, callers end up parsing error messages or catching broad error types just to learn what happened.
When an operation has several expected outcomes, returning a small result object makes them part of the contract. The discount function below reports "not found" and "expired" as data, each with a stable machine-readable code, and returns the adjusted order on success.
function applyDiscount(order, discount) {
if (!discount) {
return {
ok: false,
code: "DISCOUNT_NOT_FOUND",
message: "The requested discount does not exist.",
};
}
if (discount.expiresAt < new Date()) {
return {
ok: false,
code: "DISCOUNT_EXPIRED",
message: "The discount has expired.",
};
}
return {
ok: true,
value: {
...order,
total: order.total - discount.amount,
},
};
}
Callers can now branch on ok and code without reading human-facing text. The exact field names matter less than using one shape consistently: some projects model this as a TypeScript discriminated union on ok, others use success, data and error. What matters is that the outcome is explicit. In a real implementation you would also decide what happens when the discount exceeds the order total, since the subtraction shown here can produce a negative amount.
Result objects are not a replacement for exceptions everywhere. Throw when a dependency fails unexpectedly, when an invariant is violated, or when the current layer cannot do anything useful about the problem. Return results when failure is a normal part of the domain and the caller is expected to act on it.
A side benefit is testability: the failure cases are spelled out in the return type, so they are easy to assert. And a broad try/catch no longer lumps a rejected business rule together with a dropped database connection.
6. AbortController makes async ownership explicit
Many frontend bugs come from overlapping async work. A user edits a search term, a second request starts, and the first one resolves last. Every request succeeded, yet the screen shows stale results.
The request is not the problem; ownership is. The application has to decide which in-flight result is still allowed to update state. AbortController expresses that decision:
- In a search box, abort the previous request whenever the query changes.
- In a component, abort pending work when it unmounts.
- In a workflow of dependent steps, pass one shared
signalthrough all of them so a single abort stops the whole chain.
Handle cancellation on purpose. An abort usually means the application decided the result no longer matters, which is different from a failure. With fetch, an aborted call rejects with an error whose name is "AbortError", so you can recognize it explicitly. Logging every abort as an error floods your monitoring with noise, while treating real network failures as aborts throws away useful evidence.
The more an app relies on background fetching, live search, route transitions and chained requests, the more this matters. Starting async work in JavaScript is effortless; AbortController defines when that work stops belonging to its caller. For a deeper look at the search case, see how to fix race conditions that debouncing cannot solve.
7. Promise.allSettled keeps partial results from independent tasks
Promise.all fits when the combined result is meaningless unless every operation succeeds. It is too aggressive when tasks are independent: one rejection discards every value that did arrive.
Consider a dashboard assembled from sales figures, notifications, recent sign-ups and system health, each from a different source. If an optional service fails, blanking the whole dashboard is often a worse experience than rendering what loaded and flagging what did not.
Promise.allSettled waits for every promise and reports each outcome individually, as an object with status: "fulfilled" and a value, or status: "rejected" and a reason. The caller can use the successful values, list the failures, and decide whether the overall operation still makes sense.
Do not reach for it just to make a batch look resilient. Some groups of promises are, in business terms, a single transaction. If creating an order, reserving stock and charging a card must succeed or fail together, collecting settled results leaves you with inconsistent state; you need compensating actions or a stronger orchestration strategy.
Use allSettled when the tasks really are independent and the caller has a defined plan for partial failure. The real question is not which Promise helper is convenient, but whether an honest answer is still possible once one piece has failed. More options are covered in nine Promise patterns for reliable async JavaScript.
8. Pure transformation functions make data rules easy to test
A lot of application code interleaves data shaping with fetching, logging, rendering, persistence and state updates. Testing one simple rule then requires rebuilding the whole environment around it.
Pulling stable transformations into small pure functions fixes that. A pure function takes input, returns output, and changes nothing outside itself. Typical candidates normalize an API record, compute derived values, group items, assemble query objects, or map domain data into a view model.
The deeper benefit is a visible boundary between getting data and interpreting it. A function that groups employee records does not need to know how they were loaded. A function that builds a request payload does not need component state if the values can be passed as arguments.
Debugging gets easier too. When correct input yields incorrect output, you can exercise the transformation on its own. If the transformation is right, the investigation moves to whatever produced the input or consumed the output.
Not every expression deserves its own function; a pile of one-line wrappers makes navigation harder without adding meaning. Extract when the transformation encodes a business rule you reuse, branches in several ways, or merits its own tests.
9. Factories surface dependencies and shared configuration
A factory is one of the simplest ways to control what a module depends on. Rather than importing a database client, logger, cache or config from a global location, the factory receives them as arguments and returns functions that close over them.
This is especially handy in backend services and shared modules. Dependencies become visible, hidden coupling drops, and tests can pass a fake repository or logger without mocking module imports, while production passes the real ones. The business logic stays about behavior, not about constructing its collaborators.
Factories also suit groups of functions that share stable configuration. An HTTP client factory can capture a base URL, auth token, timeout and logger once and expose methods that all use them, with closures carrying the configuration so it is not threaded through every call.
The risk is building a homegrown dependency-injection framework when the codebase already ships one, or a plain import would do. Use factories when dependencies meaningfully change behavior, when tests need control over them, or when several differently configured instances must coexist. The goal is to reveal dependencies, not to create yet another framework to maintain.
10. Narrow Map and WeakMap caches skip repeated work
Map and WeakMap make good local caches when the same expensive computation or object-specific metadata would otherwise be recomputed. A parser can memoize results per input string. A formatter can reuse costly derived data. A WeakMap, whose keys must be objects, can attach metadata to those objects without keeping them alive once nothing else references them.
Writing the cache is the easy part. Knowing whether a cached value is still valid is the hard part.
Caches go wrong in predictable ways:
- The key omits an input that affects the result, so a cache that looks fine in tests serves stale values in production.
- The underlying data changes and nothing invalidates the entry.
- Growth is unbounded, as with a module-level
Mapthat lives for the entire life of a server process.
Before adding a cache, identify a real repeated cost and define its owner, key structure, lifetime and invalidation rule. Often recomputing is safer and fast enough. Caching earns its place when the work is expensive, the inputs are stable and the scope can stay narrow.
This pattern illustrates the whole list well. Map makes caching trivial to write, but easy is not the same as correct: the speedup is only real if you can explain when an entry stops being true.
Patterns should remove decisions, not hide them
Taken together, these patterns earn their place by removing ambiguity rather than by showcasing language features. Guards dispose of invalid paths early; normalizing at the edge hands internal code predictable values; options objects keep calls legible and open to extension; lookup maps expose direct relationships; result objects keep outcomes meaningful; AbortController assigns ownership to async work; allSettled reports partial failure honestly; pure functions isolate decisions from side effects; factories surface dependencies; and small caches skip repeated work when their validity is clear.
Every one of them can also be misapplied. Wrapping a two-argument call in an options object is pure ceremony; returning results from code that ought to throw muddies it; a map can bury ordered logic; a cache can go stale; and a factory can be abstraction nobody needed. A pattern turns harmful when it is picked for how polished it appears instead of for the real pressure it relieves.
Key takeaways
- Judge a pattern by the trade-off it makes, not by how sophisticated it looks.
- Prefer structures whose costs are visible and whose benefits survive later changes.
- Good patterns make a function's assumptions, responsibilities, failure modes and valid paths explicit.
- The best ones fade into the background: readers notice the problem getting simpler, not the pattern itself.