JavaScript design patterns pay off through recognizable code

A JavaScript team can know more design patterns and still make its codebase harder to understand. The business value comes from a small set of conventions that engineers recognize and apply consistently. For a VP of Engineering or an engineering lead, the useful question is whether those conventions help reviewers and new engineers understand a design before examining its implementation.

A familiar pattern name gives readers an expectation about structure and responsibility. When someone says "this wants a Strategy", four words can replace ten minutes explaining a design at a whiteboard. The exchange works when everyone understands which rules will vary, how callers will select them, and what interface those rules share. Strategy names that arrangement: interchangeable rules behind a common interface.

The same shortcut applies when a developer proposes "we'll do a request Adapter". An Adapter translates one interface into another, so a reviewer can start by checking the translation. Familiar structures also help a new engineer navigate the codebase. Both benefits depend on the implementation meeting the expectation its name creates.

Understanding existing code takes substantial time

The work these conventions aim to simplify already takes up a substantial part of software development. A field study published in IEEE Transactions on Software Engineering followed 78 professional developers across seven industrial projects, recording 3,148 working hours. Roughly 58% fell into the researchers' category of "program comprehension": work spent understanding code. The participating companies primarily used Java and .NET, which defines the population behind that percentage.

Sonar's 2026 State of Code measures a different burden. Sonar reports that less frequent AI users spend an average of 24% of their working week on toil, including debugging poorly documented code and understanding existing systems. Sonar has a commercial interest in teams prioritizing code quality. Toil and program comprehension describe different activities in different populations and must remain separate measures.

For a team choosing conventions, the practical question is where that understanding work arises. Developers may need to trace inherited behavior, isolate shared state between tests, fit more requirements into a strained abstraction, or maintain older decorator behavior for a framework. The following examples show how these tasks arise and which responsibilities a recognizable convention can make easier to find. Their usefulness depends on choosing a convention that fits the requirement and applying it consistently.

Okoone experts
LET'S TALK!

A project in mind?
Schedule a 30-minute meeting with us.

Senior experts helping you move faster across product, engineering, cloud & AI.

Please enter a valid business email address.

JavaScript conventions can use native language mechanisms

Choosing a suitable implementation starts with the language the team uses. Addison-Wesley published Design Patterns: Elements of Reusable Object-Oriented Software in October 1994, with 23 patterns illustrated in C++ and Smalltalk. Its authors, Erich Gamma, Richard Helm, Ralph Johnson, and John Vlissides, became known collectively as the Gang of Four, or GoF. JavaScript first appeared in December 1995, so the original examples predate its language model.

The GoF catalog groups designs by the problems they address. These categories help a reviewer identify the responsibility under discussion before considering an implementation. A JavaScript team can then choose language mechanisms that serve that responsibility.

Category Responsibility
Creational Control how objects are created.
Structural Organize components and limit unnecessary dependencies.
Behavioral Organize communication among existing components.

JavaScript can implement these responsibilities with functions, closures, and native modules. A closure lets a function retain access to surrounding bindings after the enclosing function has returned; a native module exposes selected bindings through JavaScript's standard import and export mechanism. Plain Factory and Module are also useful JavaScript idioms. The GoF catalog instead contains Factory Method and Abstract Factory, and has no Module entry.

Inheritance is another reason JavaScript implementations differ from the original examples. When a property is absent from an object, JavaScript follows that object's prototype reference and searches along the chain. The search stops when it finds the property or reaches an object for which Object.getPrototypeOf() returns null. Lookup delegates along the chain without copying ancestor properties, and MDN describes classes primarily as an abstraction over this prototype-based inheritance.

Delegation creates reading work when an object's own definition does not explain its behavior. An engineer may need to inspect successive ancestors to find that behavior, and longer chains make the search harder. Some senior JavaScript engineers bring that skill from earlier projects. The team should still make its inheritance structure understandable to engineers learning it.

Privacy also depends on the mechanism chosen. JavaScript has no syntax for a private constructor, although a module, closure, or runtime guard can restrict construction. Class #private fields have privacy enforced by the JavaScript engine, which older constructor-function syntax cannot directly express. Closures and WeakMap, a collection that can associate hidden data with objects, can provide comparable concealment without classes.

Creational choices should make construction and scope explicit

For shared resources such as connection pools and feature-flag clients, the first decision is the boundary within which sharing applies. A Singleton provides a common instance inside that boundary. ECMAScript (ES) modules often supply this behavior directly: imports resolve to a module identity, the module is evaluated once within its graph of imports, and exported functions use its top-level state.

"`javascript
// db.js
const pool = new Map();

export function getConnection(id)

export function addConnection(id, conn)
"`

Four importing files that resolve to this same module instance use the same Map. Module evaluation supplies the shared-instance mechanism, so an explicit Singleton class adds no necessary behavior here. The familiar getInstance() name comes from Java and GoF examples. It names an interface exposed to callers rather than a built-in JavaScript or TypeScript API.

The instance's scope still needs to be explicit. "The one" instance belongs to a particular module graph, while a worker, another process, or a second package copy under node_modules can receive a separate instance. Counting importers therefore tells engineers little about deployment-wide instance counts. Coordination through module state must account for those separate execution environments.

Within each environment, shared mutable state also needs an owner. Tests can become order-dependent unless they reset or isolate the state, while request-specific data at module scope can persist between server-rendered requests. Reserve module-level sharing for resources that genuinely need it, and inject those resources into business logic where practical. Native module support simplifies construction while leaving these state-management responsibilities in place.

A Factory addresses construction itself: callers request a configured object through a function that manages its setup. The factory receives configuration, validates it, derives private state, and returns public operations. This arrangement is useful when creating several entity types or when setup rules would otherwise be repeated across call sites.

"`javascript
const RANKS = ;

export function createLogger( = )

const apiLog = createLogger();
apiLog.warn("rate limit at 80%");
"`

Validation establishes which configuration the factory accepts. Object.hasOwn() limits valid levels to the object's own properties, whereas level in RANKS would also accept inherited keys such as "toString" because in searches the prototype chain. After validation, the selected threshold controls which messages reach console[rank](prefix + msg). The 80% value is fictional message content demonstrating the configured logger.

The returned methods retain access to threshold and write through a closure, while callers have no direct access to either binding. One factory can produce differently configured loggers with the same public operations and separate enclosed settings. If setup rules change, the team can update the factory instead of changing every caller. Functions provide both runtime privacy and controlled construction here.

Prototype introduces a naming ambiguity that a review cannot settle by saying only "prototype." GoF Prototype creates copies from a configured exemplar, while JavaScript prototypes provide delegated property lookup. The requirement must therefore establish whether callers need inherited behavior or independent data.

"`javascript
const baseVehicle = ;

const myCar = Object.create(baseVehicle);
myCar.make = "Toyota";

console.log(myCar.wheels); // 4

const baseConfig = ;

const testConfig = structuredClone(baseConfig);
testConfig.headers.agent = "test";

console.log(baseConfig.headers.agent); // "core"
"`

The first operation, Object.create(baseVehicle), links the new object to baseVehicle, so myCar.wheels resolves through delegation. The "Toyota" value is sample data attached to that new object. The second operation, structuredClone(baseConfig), makes an independent deep copy of supported data. Changing the nested agent value therefore leaves the original configuration unchanged.

Independent copying has a specific contract. structuredClone() supports circular references in supported data, but a function causes DataCloneError; property descriptors, getters and setters, custom prototype chains, and class private elements are not preserved. A copied class instance therefore cannot be expected to retain its original prototype behavior. The function supplies the supported-data-copying portion of Prototype, and reviewers need to check that callers require this behavior.

Structural boundaries should contain dependency changes

Once construction is explicit, the next question is how dependencies reach the rest of the application. Replacing an SMS provider, payment processor, or search backend might affect one file or three hundred, illustrative scopes that show why the boundary matters. Structural conventions give private implementation, external translation, and added behavior clear places in the code. Those places determine how far a dependency change spreads.

For private implementation and a public interface, native ES modules are the normal JavaScript choice. Before native modules, immediately invoked functions, functions executed as soon as they are defined, and other conventions created private and public boundaries. ES modules express the boundary directly by keeping internal bindings unexported and exposing the operations callers need.

"`javascript
// inventory.js
const cache = new Map();

export function getSku(sku)

export async function refresh()
"`

Consumers can call getSku() and refresh(), while the unexported cache stays internal. Refreshing follows a visible sequence: request "/api/inventory", read the JSON response, then store each item under item.sku. This is the Module convention implemented through the language's own boundary. A separate Module-pattern construction would need an additional requirement to justify it.

An Adapter extends boundary-setting to an interface controlled by someone else. The application defines its own operation, and an implementation translates that operation into the external API. Third-party software development kits, or SDKs, and legacy APIs are common reasons to create this boundary. It is especially useful when replacing the dependency is a realistic requirement.

"`javascript
class VendorSms

class SmsGateway

const gateway = new SmsGateway(new VendorSms());
"`

Callers use SmsGateway.notify(recipient, message), and the adapter translates that request into VendorSms.send(to, body). If the vendor deprecates send(), engineers have a clear place to change the translation. Purchasing could also find a better deal while application code keeps its existing vocabulary. Reviewers should check that vendor-specific assumptions stay inside this boundary.

A Decorator creates a different boundary by adding behavior around an existing operation. Logging, authorization, retries, and telemetry can wrap a function or object while leaving its implementation unchanged. This lets engineers extend individual objects without expanding an inheritance tree. React higher-order components, which wrap components to add behavior, use a similar approach.

"`javascript
function withRetry(fn, attempts = 3)

const chargeCard = withRetry(rawChargeCard);
"`

The wrapper forwards both arguments and the calling this through fn.apply(this, args). Its loop starts at one, continues through the configured number of attempts, returns the awaited result on success, and throws the error after the last failed attempt. The operation and retry procedure remain separate, making both responsibilities visible. For a payment operation such as rawChargeCard, the review must also establish that repeating a call is safe.

Combining wrappers makes execution order another responsibility. For example, withLogging(withRetry(rawChargeCard)) places logging around the outer call while the retry wrapper controls attempts inside it. The name Decorator helps only when the team shares an understanding of that order. Compiler-supported @ syntax adds a separate compatibility decision, addressed after the runtime conventions.

Behavioral conventions need lifecycle and selection rules

Boundaries identify where behavior lives; behavioral conventions identify how components interact. Observer lets a changing subject notify an arbitrary set of subscribers without knowing their identities or counting them. Event-driven flows, reactive interfaces, and real-time feeds use this arrangement, including listeners on the browser's Document Object Model, or DOM, and Node's EventEmitter. The notification rule is simple, but each subscription also has a lifetime.

"`javascript
export function createStore(initial)
"`

The store keeps state and listeners private, returns the current state from getSnapshot(), and replaces state before notifying subscribers. Writes should replace the snapshot rather than mutate it in place, so reads can reuse a stable reference until a change occurs. Each subscription returns an unsubscribe function that deletes its callback from the set. The subject owns notification, while the subscriber owns calling that cleanup function.

Cleanup ends the subscription's lifetime and prevents the lapsed listener leak. An unremoved callback can keep receiving notifications after its component unmounts, and it can retain its closure and everything that closure captures. A reviewer who recognizes Observer should therefore follow the subscription through to cleanup. The notification mechanism alone leaves that ownership unresolved.

React's useSyncExternalStore provides an integration with explicit rules for both subscription and snapshots. Its subscribe function must return an unsubscribe function, and getSnapshot must return a cached value instead of creating a fresh object on every read. The API supports consistent external-store snapshots during concurrent rendering, when React can prepare rendering work in overlapping stages; server rendering also requires getServerSnapshot. For React teams operating at scale, a subscription without corresponding cleanup should block review.

Strategy moves from managing subscribers to selecting among interchangeable business rules. A growing switch statement can become the place where several algorithms accumulate. Extracting those algorithms into functions or objects gives them a common calling convention. Selection then becomes an explicit operation.

"`javascript
const shipping = ;

function quote(method, weightKg)
"`

The formulas are illustrative shipping rules with no implied currency or commercial rates. quote() validates that the requested method is an own property, then invokes it with the weight. A truthy lookup would wrongly admit an inherited function such as shipping["toString"]. The prototype-awareness used in the logger factory also applies at this selection boundary.

Once selection is controlled, adding a fourth shipping method requires another key while leaving the existing strategies unchanged. This limits the existing behavior a developer needs to edit for the change. The convention gives every replaceable rule a shared signature. JavaScript functions provide that contract directly.

TypeScript makes some contracts compiler-checkable

The runtime examples leave several expectations for engineers and tests to check. TypeScript can check some before execution: an interface describes the shape an adapter or strategy must provide, while generic event definitions use type parameters to describe intended Observer payloads. Interfaces disappear from emitted JavaScript, however, so their checks coexist with runtime validation. Shared state and subscription ownership still need explicit decisions.

Construction and privacy show why the distinction matters. A TypeScript private constructor restricts calls during compilation, while the emitted JavaScript has no equivalent private-constructor enforcement. JavaScript closures and #private fields provide concealment during execution independently of the type checker. The required restriction determines which mechanism to choose.

Concern JavaScript baseline TypeScript addition Relevant examples
Encapsulation Closures and #private provide runtime privacy. private, protected, and readonly impose type-checker restrictions. Module and some Singleton implementations
Contracts Tests or explicit validation check expected structure. Interfaces check shape during compilation and are erased afterward. Adapter and Strategy
Initialization Constructors assign fields explicitly. Parameter properties combine field declaration and initialization. Factory and Adapter
Payload shape Published values determine the runtime payload. Generic event definitions check intended payload shapes. Observer

For the earlier inventory module, types can make both stored data and lookup failure explicit. A Sku contains a string identifier and a numeric stock count, and the cache maps strings to that shape. The return type tells callers that a requested item may be absent.

"`typescript
// inventory.ts
export type Sku = ;

const cache = new Map();

export function getSku(sku: string): Sku | undefined

// consumer.ts
import type from "./inventory";
import from "./inventory";
"`

The two imports serve different purposes. The import type declaration is erased, so that shared type-only import contributes zero bytes to the production bundle. The value import supplies getSku for runtime use. Types and executable exports can share a module boundary while having different effects on generated code.

For the SMS boundary, an interface makes the application's required notification contract explicit. SmsGateway implements Notifier requires the compiler to check that contract. A replacement adapter with the wrong shape fails checking before execution.

"`typescript
declare class VendorSms

interface Notifier

class SmsGateway implements Notifier {
constructor(private client: VendorSms)

async notify(recipient: { phone: string }, message: string) {
await this.client.send(recipient.phone, message);
}
}
"`

The declared contract requires Promise, a promise that completes without returning a value. The asynchronous notify() preserves that contract by awaiting the vendor operation. Meanwhile, constructor(private client: VendorSms) is a parameter property: it declares and initializes the field in the constructor signature. These checks and shorthand declarations leave the translation itself for engineers to review.

Strategy receives a similar compiler check through ShippingRate. The interface includes a display label and a pricing operation, allowing callers to accept a rate through a common contract. Omitting price() becomes a build-time failure instead of an error discovered during checkout.

"`typescript
interface ShippingRate

const express: ShippingRate = ;

function quote(rate: ShippingRate, weightKg: number)
"`

Language adoption provides context for using these contracts, although adoption measures answer different questions. GitHub's Octoverse 2025 counted 2,636,006 monthly TypeScript contributors in August 2025, placing it first by that activity measure and roughly 42,000 contributors ahead of Python. GitHub has a commercial stake in developer activity through its development platform. Stack Overflow's 2025 developer survey placed JavaScript first for reported language use, at 66% of respondents answering that question; contributor activity and self-reported use can produce different rankings because they measure different things.

Compiler performance is another consideration for teams using TypeScript. Microsoft made TypeScript 7.0 generally available on July 8, 2026, using a native Go port of the compiler, and reported a 10x speed improvement with results varying by workload. Microsoft has a commercial stake in adoption of its developer ecosystem. Faster compilation can improve build performance while leaving runtime responsibilities, including shared module state, unchanged.

Decorator modernization must follow framework compatibility

Compiler changes also affect which decorator syntax a project can use. The earlier retry wrapper implements Decorator through an ordinary function, while TypeScript and ECMAScript @ transformations operate on classes and class elements. Their shared terminology does not automatically make an @ transformation an implementation of the GoF pattern. The design requirement and the compiler's transformation rules therefore need separate decisions.

The compiler decision matters because TypeScript supports incompatible decorator models. TypeScript 5.0, released in March 2023, introduced support for newer ECMAScript decorators without requiring experimentalDecorators. Enabling that flag selects the older semantics, the rules for how decorators are checked, emitted, and called. The choice changes both the calling convention and available capabilities.

Compatibility point Legacy decorators Newer decorators
Method decorator arguments Target, property key, and descriptor Method and context object
Parameter decorators Supported Unsupported
emitDecoratorMetadata Compatible with the legacy model Incompatible
Compiler selection Selected by experimentalDecorators Supported without that flag

A timing decorator shows the newer calling convention. It receives the original method and a ClassMethodDecoratorContext, obtains a label from String(context.name), and returns an asynchronous wrapper. The awaited call sits inside try, so finally ends timing after the operation settles.

"`typescript
function timed(
originalMethod: any,
context: ClassMethodDecoratorContext
)

class Reports
"`

Existing frameworks can require the older model instead. Many Angular, NestJS, and TypeORM projects depend on legacy decorators, and some use parameter decorators or emitted constructor metadata, information about constructor types made available at runtime. Their mechanisms differ, so compatibility must be checked against the exact framework versions in use. Switching semantics can break those dependencies even when the visible @ notation looks familiar.

The compiler setting also applies across a compilation: legacy semantics cannot serve some decorators while newer semantics serve others. Microsoft has said that experimentalDecorators will remain available for the foreseeable future. For projects that require the older behavior, keeping the flag can therefore be a defensible maintenance decision.

A migration should begin by identifying framework versions, validating their support, and selecting compatible semantics before scheduling changes. Treating the work as routine compiler-flag cleanup reverses that order. Teams maintaining metadata-dependent applications need an upgrade plan that respects those dependencies. The newer model becomes usable when the project's framework requirements allow it.

An abstraction must earn the work it creates

Compatibility decisions show how a convention can create obligations beyond its visible code. Language features can also eliminate the need for an explicit pattern implementation. In 1996, Peter Norvig found that 16 of the 23 GoF patterns became invisible or simpler in Lisp because the language supplied their machinery. First-class functions, functions that can be passed and stored as values, and modules were relevant to that comparison, and JavaScript has both.

Paul Graham made a sharper argument in Revenge of the Nerds: "When I see patterns in my programs, I consider it a sign of trouble." Graham's concern was that repeated structure can reveal an inadequate abstraction, including cases where programmers manually repeat what a macro could express through code transformation. A hypothetical name such as AbstractHandlerFactoryProvider captures the risk of accumulating layers. Each layer needs to give the reader useful information.

An initially useful layer can also deteriorate as requirements change. Sandi Metz describes this process in The Wrong Abstraction: an engineer creates common code, then a near-fit requirement prompts another engineer to add parameters. Further requirements bring more accommodations, while reluctance to change the established design allows conditional branches to accumulate. The file becomes harder to modify, leading Metz to conclude that duplication can cost less than an unsuitable abstraction.

The mechanism is a change in how engineers evaluate new requirements. Once preserving the shared abstraction becomes the priority, each requirement is judged by how it can fit inside that abstraction. Parameters and branches become the accepted response, even when they obscure the original behavior. Everyone who later changes the file must understand the accumulated exceptions.

Dan Abramov describes a separate experience in the React ecosystem in Goodbye, Clean Code. He replaced a colleague's repetitive implementation with a more elegant abstraction, then saw it become the project's hardest file to modify. His advice was: "Let clean code guide you. Then let it go." The account shows how an abstraction chosen for elegance can make later changes harder.

A review can address this risk by asking for the requirement behind each extension point. A demonstrated need gives reviewers something concrete against which to judge the added interface. A hook for a possible future requirement immediately creates reading work, so its proposed use needs to justify that cost. The abstraction should serve a requirement the team can name.

This test leaves room for approaches outside the GoF catalog. Its patterns remain useful professional vocabulary, while serious systems can also be built in a functional style without depending on that catalog. Many senior engineers reportedly consult Patterns of Enterprise Application Architecture first when facing a practical design question. For JavaScript teams using functions and prototype-oriented designs, a class-based, reuse-first default can introduce machinery that the requirement does not need.

Standardize four or five useful conventions, and enforce them

Choosing suitable abstractions becomes easier when the repository has a small, explicit set of conventions. Two partially implemented patterns can create more discovery work than one adequate pattern used consistently, because readers must first determine which convention applies. A shared name compresses communication when the code gives that name a stable meaning.

Start by identifying the four or five patterns the codebase actually uses, then document them in a one-page guide. Give each an example and a purpose, introduce the guide on an engineer's first day, and refer to it during reviews. Enforce the documented choices and exclude patterns that have no local use. Documentation and review enforcement are often neglected, yet they turn individual preferences into a convention the team can rely on.

The guide should record implementation decisions reviewers can check: where shared state is permitted, how adapters translate dependencies, who cleans up subscriptions, and which decorator semantics the frameworks require. Plain functions, closures, native ES modules, and existing framework APIs are valid implementations when they meet the documented requirement. For each proposed abstraction, require a named use and an example of the change it must support. Reviewers should approve a simpler implementation when it satisfies those requirements.

Concluding thoughts

For engineering leaders, the value of JavaScript design patterns lies in how reliably teams can understand and change the code. A familiar name helps only when its implementation meets a shared expectation. Adding more patterns to the codebase does not establish that consistency.

Treat conventions as operating decisions. Engineers need to know where shared state belongs, how external dependencies are contained, and who owns subscription cleanup. TypeScript can check some contracts before execution, but it cannot settle those ownership questions. Framework compatibility also needs to shape modernization plans, particularly where applications depend on legacy decorators.

Support a small, documented set of conventions through onboarding and code review. Ask teams to justify new abstractions against requirements they can name, rather than possible future uses. Native modules, functions, and closures may meet those requirements with less machinery.

Judge the result by the work engineers must do to make a change. Can a reviewer locate the relevant responsibility? Can a new engineer understand the boundary? Can a dependency change stay contained? Those questions give leaders a more useful basis for evaluating design choices than the number of patterns a team adopts.

Alexander Procter

October 16, 2026

22 Min

Okoone experts
LET'S TALK!

A project in mind?
Schedule a 30-minute meeting with us.

Senior experts helping you move faster across product, engineering, cloud & AI.

Please enter a valid business email address.