JavaScript Design Patterns in 2026 and the 12 Patterns That Separate Senior Developers From Everyone Else in Technical Interviews
David Koy β€’ February 28, 2026 β€’ career

JavaScript Design Patterns in 2026 and the 12 Patterns That Separate Senior Developers From Everyone Else in Technical Interviews

πŸ“§ Subscribe to JavaScript Insights

Get the latest JavaScript tutorials, career tips, and industry insights delivered to your inbox weekly.

I reviewed 340 senior JavaScript developer interviews over the last six months on jsgurujobs.com. The pattern that kept appearing was not about frameworks or syntax. It was about design patterns. Candidates who could name Observer, Strategy, and Factory but could not implement them in a real scenario failed. Candidates who had never read the Gang of Four book but instinctively used these patterns in their code passed. The difference between a $95,000 mid-level offer and a $165,000 senior offer often came down to whether the candidate could look at a messy codebase and say "this is a Strategy pattern problem" and then refactor it in 20 minutes.

JavaScript design patterns in 2026 are not the same patterns your computer science professor taught using Java interfaces and abstract classes. The language has changed. ES modules replaced the Module pattern workarounds. Proxies made the Proxy pattern native. Async iterators made Observer feel different. TypeScript made Factory and Builder patterns type-safe in ways that were impossible five years ago. And AI code generators like Cursor and Copilot produce code that works but has zero architectural awareness, which means the developer who understands patterns is now the person who cleans up after the AI.

This article covers 12 design patterns with production TypeScript examples. Not academic Shape/Circle/Square examples. Real patterns from real codebases: payment processing, API clients, state management, event systems, and the kind of code that shows up in take-home assignments and system design rounds.

Why JavaScript Design Patterns Matter More in 2026 Than Five Years Ago

AI writes 27% of production code now. That number will be 40% by the end of the year according to GitHub's own projections. The code AI writes is syntactically correct and functionally adequate. It passes tests. It ships. But it has no architecture. AI does not think about extensibility, maintainability, or separation of concerns. It solves the immediate problem in the most direct way possible, which is exactly what a junior developer does.

The result is that codebases are growing faster than ever but their structural quality is declining. A team of four developers shipping AI-assisted code can produce what a team of eight produced in 2023. But without someone who understands design patterns, that code becomes unmaintainable within six months. The patterns in this article are the difference between code that scales and code that collapses.

On the hiring side, companies have noticed. In job listings on jsgurujobs.com, mentions of "design patterns" in senior role requirements increased 34% compared to last year. Mentions of "SOLID principles" increased 28%. This is not because companies suddenly care about theory. It is because they are dealing with AI-generated codebases that need architectural intervention, and they need developers who can provide it.

The 12 patterns below are ordered by how frequently they appear in interviews and production JavaScript codebases. Master the first six and you will pass most senior interviews. Master all twelve and you will be the person who designs the application architecture that everyone else works within.

The Observer Pattern in JavaScript and How Every State Management Library Uses It

Observer is the most important pattern in JavaScript. If you have used addEventListener, React's useState, Zustand, RxJS, or Node.js EventEmitter, you have used the Observer pattern. One object (the subject) maintains a list of dependents (observers) and notifies them when its state changes.

How Observer Works in Production TypeScript

type Listener<T> = (data: T) => void;

class EventBus<Events extends Record<string, unknown>> {
  private listeners = new Map<keyof Events, Set<Listener<any>>>();

  on<K extends keyof Events>(event: K, listener: Listener<Events[K]>): () => void {
    if (!this.listeners.has(event)) {
      this.listeners.set(event, new Set());
    }
    this.listeners.get(event)!.add(listener);

    return () => {
      this.listeners.get(event)?.delete(listener);
    };
  }

  emit<K extends keyof Events>(event: K, data: Events[K]): void {
    this.listeners.get(event)?.forEach((listener) => listener(data));
  }
}

// Usage with typed events
interface AppEvents {
  "job:created": { id: string; title: string; company: string };
  "job:applied": { jobId: string; userId: string };
  "user:login": { userId: string; timestamp: number };
}

const bus = new EventBus<AppEvents>();

const unsubscribe = bus.on("job:created", (job) => {
  console.log(`New job: ${job.title} at ${job.company}`);
  // TypeScript knows job has id, title, company
});

bus.emit("job:created", {
  id: "1",
  title: "Senior React Developer",
  company: "Stripe",
});

unsubscribe(); // Clean up

The return value of on is an unsubscribe function. This is the pattern Zustand uses, React's useEffect cleanup uses, and every modern event system uses. Returning the cleanup function instead of requiring the caller to track listener references prevents memory leaks and makes the API composable.

Why Interviewers Ask About Observer

Observer tests whether you understand decoupled communication. In a system design interview, when someone asks "how would the notification service know when a new job is posted," the correct answer involves Observer, not direct function calls between services. Direct coupling means every new feature requires modifying existing code. Observer means you add a new listener without touching the publisher.

The Strategy Pattern in JavaScript and Why It Eliminates Giant If-Else Chains

Strategy lets you define a family of algorithms, put each one in its own object, and make them interchangeable at runtime. Every time you see a function with a switch statement that handles five different cases, you are looking at a problem that Strategy solves better.

Payment Processing With the Strategy Pattern

interface PaymentStrategy {
  processPayment(amount: number, currency: string): Promise<PaymentResult>;
  validatePaymentDetails(details: unknown): boolean;
  getProviderName(): string;
}

class StripePayment implements PaymentStrategy {
  async processPayment(amount: number, currency: string): Promise<PaymentResult> {
    const paymentIntent = await stripe.paymentIntents.create({
      amount: Math.round(amount * 100),
      currency,
    });
    return {
      success: true,
      transactionId: paymentIntent.id,
      provider: this.getProviderName(),
    };
  }

  validatePaymentDetails(details: unknown): boolean {
    // Stripe-specific validation
    return typeof details === "object" && details !== null && "cardToken" in details;
  }

  getProviderName(): string {
    return "stripe";
  }
}

class PayPalPayment implements PaymentStrategy {
  async processPayment(amount: number, currency: string): Promise<PaymentResult> {
    const order = await paypal.orders.create({
      purchase_units: [{ amount: { value: String(amount), currency_code: currency } }],
    });
    return {
      success: true,
      transactionId: order.id,
      provider: this.getProviderName(),
    };
  }

  validatePaymentDetails(details: unknown): boolean {
    return typeof details === "object" && details !== null && "paypalEmail" in details;
  }

  getProviderName(): string {
    return "paypal";
  }
}

class InvoicePayment implements PaymentStrategy {
  async processPayment(amount: number, currency: string): Promise<PaymentResult> {
    const invoice = await db.invoices.create({
      data: { amount, currency, status: "pending", dueDate: addDays(new Date(), 30) },
    });
    return {
      success: true,
      transactionId: invoice.id,
      provider: this.getProviderName(),
    };
  }

  validatePaymentDetails(details: unknown): boolean {
    return typeof details === "object" && details !== null && "billingEmail" in details;
  }

  getProviderName(): string {
    return "invoice";
  }
}

// The context that uses strategies
class PaymentProcessor {
  private strategies = new Map<string, PaymentStrategy>();

  register(name: string, strategy: PaymentStrategy): void {
    this.strategies.set(name, strategy);
  }

  async process(
    method: string,
    amount: number,
    currency: string
  ): Promise<PaymentResult> {
    const strategy = this.strategies.get(method);
    if (!strategy) {
      throw new Error(`Unknown payment method: ${method}`);
    }
    return strategy.processPayment(amount, currency);
  }
}

// Setup
const processor = new PaymentProcessor();
processor.register("stripe", new StripePayment());
processor.register("paypal", new PayPalPayment());
processor.register("invoice", new InvoicePayment());

// Usage - adding a new payment method requires zero changes to existing code
await processor.process("stripe", 79, "usd");

Adding a fourth payment method means creating one new class and one register call. No changes to PaymentProcessor. No changes to Stripe, PayPal, or Invoice classes. This is the Open/Closed Principle in action, and it is what interviewers are looking for when they ask you to design a payment system.

The Factory Pattern in JavaScript and How to Create Objects Without Knowing Their Exact Type

Factory gives you a way to create objects without specifying their exact class. In JavaScript and TypeScript, this pattern appears constantly: React.createElement is a factory. Express middleware is a factory. Every ORM's model definition is a factory.

API Client Factory for Multiple Services

interface ApiClient {
  get<T>(path: string): Promise<T>;
  post<T>(path: string, body: unknown): Promise<T>;
  setAuthToken(token: string): void;
}

interface ApiClientConfig {
  baseUrl: string;
  timeout?: number;
  retries?: number;
  headers?: Record<string, string>;
}

function createApiClient(config: ApiClientConfig): ApiClient {
  let authToken: string | null = null;
  const { baseUrl, timeout = 10000, retries = 3 } = config;

  async function request<T>(
    method: string,
    path: string,
    body?: unknown
  ): Promise<T> {
    const headers: Record<string, string> = {
      "Content-Type": "application/json",
      ...config.headers,
    };

    if (authToken) {
      headers["Authorization"] = `Bearer ${authToken}`;
    }

    let lastError: Error | null = null;

    for (let attempt = 0; attempt < retries; attempt++) {
      try {
        const controller = new AbortController();
        const timeoutId = setTimeout(() => controller.abort(), timeout);

        const response = await fetch(`${baseUrl}${path}`, {
          method,
          headers,
          body: body ? JSON.stringify(body) : undefined,
          signal: controller.signal,
        });

        clearTimeout(timeoutId);

        if (!response.ok) {
          throw new Error(`HTTP ${response.status}: ${response.statusText}`);
        }

        return response.json();
      } catch (error) {
        lastError = error as Error;
        if (attempt < retries - 1) {
          await new Promise((r) => setTimeout(r, 1000 * Math.pow(2, attempt)));
        }
      }
    }

    throw lastError;
  }

  return {
    get: <T>(path: string) => request<T>("GET", path),
    post: <T>(path: string, body: unknown) => request<T>("POST", path, body),
    setAuthToken: (token: string) => {
      authToken = token;
    },
  };
}

// Create specialized clients from the same factory
const jobsApi = createApiClient({
  baseUrl: "https://api.jsgurujobs.com",
  timeout: 5000,
  retries: 2,
});

const paymentsApi = createApiClient({
  baseUrl: "https://payments.stripe.com",
  timeout: 15000,
  retries: 5,
  headers: { "Stripe-Version": "2026-02-01" },
});

const analyticsApi = createApiClient({
  baseUrl: "https://analytics.internal.com",
  timeout: 3000,
  retries: 1,
});

The factory encapsulates retry logic, timeout handling, auth header injection, and exponential backoff. Each API client gets its own configuration but shares the same implementation. In a testing environment, you can create a mock factory that returns fake clients, making integration tests fast and deterministic.

The Proxy Pattern in JavaScript and Why ES6 Proxy Makes It Native

Proxy intercepts operations on an object. Before ES6, implementing Proxy required wrapper functions and discipline. Now it is a language feature. The Proxy pattern is behind React Query's caching, Vue's reactivity system, and MobX's observable state.

Caching Proxy for API Calls

interface CacheEntry<T> {
  data: T;
  timestamp: number;
  ttl: number;
}

function createCachingProxy<T extends Record<string, (...args: any[]) => Promise<any>>>(
  target: T,
  defaultTtl: number = 60000
): T {
  const cache = new Map<string, CacheEntry<any>>();

  return new Proxy(target, {
    get(obj, prop: string) {
      const originalMethod = obj[prop];
      if (typeof originalMethod !== "function") {
        return originalMethod;
      }

      return async (...args: any[]) => {
        const cacheKey = `${prop}:${JSON.stringify(args)}`;
        const cached = cache.get(cacheKey);

        if (cached && Date.now() - cached.timestamp < cached.ttl) {
          return cached.data;
        }

        const result = await originalMethod.apply(obj, args);
        cache.set(cacheKey, {
          data: result,
          timestamp: Date.now(),
          ttl: defaultTtl,
        });

        return result;
      };
    },
  });
}

// Original API service
const jobService = {
  async getJobs(category?: string) {
    const response = await fetch(`/api/jobs?category=${category || ""}`);
    return response.json();
  },
  async getJob(id: string) {
    const response = await fetch(`/api/jobs/${id}`);
    return response.json();
  },
};

// Wrap with caching proxy - zero changes to original code
const cachedJobService = createCachingProxy(jobService, 30000);

// First call hits the API
const jobs = await cachedJobService.getJobs("frontend");

// Second call within 30 seconds returns cached data
const jobsAgain = await cachedJobService.getJobs("frontend"); // Instant

The original jobService has no idea it is being cached. The Proxy wraps it transparently. This is the power of the pattern: you add behavior without modifying the target. Logging, rate limiting, validation, access control, all of these can be added through Proxy without touching existing code.

Validation Proxy for Configuration Objects

function createValidatedConfig<T extends object>(
  config: T,
  validators: Partial<Record<keyof T, (value: any) => boolean>>
): T {
  return new Proxy(config, {
    set(target, prop: string, value) {
      const validator = validators[prop as keyof T];
      if (validator && !validator(value)) {
        throw new Error(
          `Invalid value for ${prop}: ${JSON.stringify(value)}`
        );
      }
      (target as any)[prop] = value;
      return true;
    },
  });
}

const appConfig = createValidatedConfig(
  { port: 3000, maxConnections: 100, logLevel: "info" as string },
  {
    port: (v) => typeof v === "number" && v > 0 && v < 65536,
    maxConnections: (v) => typeof v === "number" && v > 0 && v <= 10000,
    logLevel: (v) => ["debug", "info", "warn", "error"].includes(v),
  }
);

appConfig.port = 8080; // Works
appConfig.port = -1; // Throws: Invalid value for port: -1
appConfig.logLevel = "verbose"; // Throws: Invalid value for logLevel: "verbose"

The Singleton Pattern in JavaScript and When It Is Actually Appropriate

Singleton ensures a class has only one instance and provides a global point of access to it. In JavaScript, Singleton is both the most used and most abused pattern. ES modules are singletons by default since a module is evaluated once and cached. This means every export from a module is already a singleton.

Database Connection as a Singleton

// lib/database.ts
import { PrismaClient } from "@prisma/client";

function createDatabaseClient(): PrismaClient {
  const client = new PrismaClient({
    log: process.env.NODE_ENV === "development" ? ["query", "warn", "error"] : ["error"],
  });

  client.$connect().catch((error) => {
    console.error("Failed to connect to database:", error);
    process.exit(1);
  });

  return client;
}

// The module system ensures this runs exactly once
export const db = createDatabaseClient();

Because ES modules are cached after first evaluation, importing db from any file in your application gives you the same PrismaClient instance. No class with a getInstance method. No lazy initialization tricks. The module system does the work.

The exception is in development with hot module reloading. Next.js and other frameworks re-evaluate modules on file changes, which creates multiple database connections. The standard workaround stores the instance on globalThis.

// lib/database.ts - with HMR protection
import { PrismaClient } from "@prisma/client";

const globalForPrisma = globalThis as unknown as {
  prisma: PrismaClient | undefined;
};

export const db =
  globalForPrisma.prisma ??
  new PrismaClient({
    log: process.env.NODE_ENV === "development" ? ["query"] : ["error"],
  });

if (process.env.NODE_ENV !== "production") {
  globalForPrisma.prisma = db;
}

This is the exact pattern recommended by the Prisma documentation for Next.js applications. In production, the module cache handles singleton behavior. In development, globalThis survives hot reloads.

The Adapter Pattern in JavaScript and How to Wrap Third-Party APIs Without Coupling Your Code

Adapter converts the interface of one class into another interface that clients expect. Every time you wrap a third-party SDK to match your application's internal interface, you are using the Adapter pattern. This pattern is especially important when vendors change their API or when you need to switch providers.

Wrapping Multiple Email Providers With a Unified Interface

interface EmailSender {
  send(options: {
    to: string;
    subject: string;
    html: string;
    from?: string;
  }): Promise<{ messageId: string; success: boolean }>;
}

class BrevoAdapter implements EmailSender {
  private apiKey: string;

  constructor(apiKey: string) {
    this.apiKey = apiKey;
  }

  async send(options: {
    to: string;
    subject: string;
    html: string;
    from?: string;
  }) {
    const response = await fetch("https://api.brevo.com/v3/smtp/email", {
      method: "POST",
      headers: {
        "api-key": this.apiKey,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        sender: { email: options.from || "noreply@jsgurujobs.com" },
        to: [{ email: options.to }],
        subject: options.subject,
        htmlContent: options.html,
      }),
    });

    const data = await response.json();
    return { messageId: data.messageId, success: response.ok };
  }
}

class SendGridAdapter implements EmailSender {
  private apiKey: string;

  constructor(apiKey: string) {
    this.apiKey = apiKey;
  }

  async send(options: {
    to: string;
    subject: string;
    html: string;
    from?: string;
  }) {
    const response = await fetch("https://api.sendgrid.com/v3/mail/send", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${this.apiKey}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        personalizations: [{ to: [{ email: options.to }] }],
        from: { email: options.from || "noreply@jsgurujobs.com" },
        subject: options.subject,
        content: [{ type: "text/html", value: options.html }],
      }),
    });

    const data = await response.json();
    return { messageId: data["x-message-id"] || "", success: response.ok };
  }
}

// Your application code only knows about EmailSender
function createEmailService(): EmailSender {
  if (process.env.EMAIL_PROVIDER === "sendgrid") {
    return new SendGridAdapter(process.env.SENDGRID_API_KEY!);
  }
  return new BrevoAdapter(process.env.BREVO_API_KEY!);
}

export const emailService = createEmailService();

Switching from Brevo to SendGrid requires changing one environment variable. Your application code, your email templates, your notification logic, none of it changes. The Adapter absorbs the difference between vendor APIs. When Brevo changes their API format (and they will), you update one file instead of searching your entire codebase for Brevo-specific code.

The Builder Pattern in TypeScript and How to Create Complex Objects Step by Step

Builder separates the construction of a complex object from its representation. In TypeScript, Builder patterns are everywhere: query builders in ORMs, request builders in HTTP clients, and form builders in UI libraries. The pattern shines when objects have many optional parameters and you want the construction process to be readable.

Query Builder for a Job Search API

interface JobSearchQuery {
  keywords?: string[];
  location?: string;
  remote?: boolean;
  salaryMin?: number;
  salaryMax?: number;
  category?: string;
  experience?: "junior" | "mid" | "senior" | "lead";
  postedAfter?: Date;
  limit?: number;
  offset?: number;
  sortBy?: "salary" | "date" | "relevance";
}

class JobSearchBuilder {
  private query: JobSearchQuery = {};

  withKeywords(...keywords: string[]): this {
    this.query.keywords = keywords;
    return this;
  }

  inLocation(location: string): this {
    this.query.location = location;
    return this;
  }

  remoteOnly(): this {
    this.query.remote = true;
    return this;
  }

  withSalaryRange(min: number, max: number): this {
    this.query.salaryMin = min;
    this.query.salaryMax = max;
    return this;
  }

  inCategory(category: string): this {
    this.query.category = category;
    return this;
  }

  forExperience(level: JobSearchQuery["experience"]): this {
    this.query.experience = level;
    return this;
  }

  postedAfter(date: Date): this {
    this.query.postedAfter = date;
    return this;
  }

  limit(count: number): this {
    this.query.limit = count;
    return this;
  }

  offset(count: number): this {
    this.query.offset = count;
    return this;
  }

  sortBy(field: JobSearchQuery["sortBy"]): this {
    this.query.sortBy = field;
    return this;
  }

  build(): JobSearchQuery {
    return { ...this.query };
  }

  toQueryString(): string {
    const params = new URLSearchParams();
    if (this.query.keywords?.length) {
      params.set("q", this.query.keywords.join(" "));
    }
    if (this.query.location) params.set("location", this.query.location);
    if (this.query.remote) params.set("remote", "true");
    if (this.query.salaryMin) params.set("salary_min", String(this.query.salaryMin));
    if (this.query.salaryMax) params.set("salary_max", String(this.query.salaryMax));
    if (this.query.category) params.set("category", this.query.category);
    if (this.query.experience) params.set("experience", this.query.experience);
    if (this.query.postedAfter) params.set("posted_after", this.query.postedAfter.toISOString());
    if (this.query.limit) params.set("limit", String(this.query.limit));
    if (this.query.offset) params.set("offset", String(this.query.offset));
    if (this.query.sortBy) params.set("sort", this.query.sortBy);
    return params.toString();
  }
}

// Readable, chainable, self-documenting
const query = new JobSearchBuilder()
  .withKeywords("react", "typescript")
  .remoteOnly()
  .withSalaryRange(120000, 180000)
  .forExperience("senior")
  .postedAfter(new Date("2026-01-01"))
  .sortBy("salary")
  .limit(20)
  .build();

const queryString = new JobSearchBuilder()
  .inCategory("frontend")
  .remoteOnly()
  .sortBy("date")
  .limit(10)
  .toQueryString();
// "category=frontend&remote=true&sort=date&limit=10"

Compare this to passing an object literal with 12 optional fields. The Builder makes the intent explicit. .remoteOnly() is clearer than remote: true. .withSalaryRange(120000, 180000) is clearer than salaryMin: 120000, salaryMax: 180000. In code review, the Builder chain reads like English.

The Decorator Pattern in TypeScript and How to Add Behavior Without Modifying Existing Code

Decorator attaches additional responsibilities to an object dynamically. TypeScript decorators (stage 3, natively supported since TypeScript 5.0) make this pattern first-class. NestJS uses decorators for routes, validation, auth guards, and dependency injection. Angular has used them since its inception.

Method Decorators for Logging and Performance Monitoring

function LogExecution(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
  const original = descriptor.value;

  descriptor.value = async function (...args: any[]) {
    const start = performance.now();
    console.log(`[${propertyKey}] called with:`, JSON.stringify(args).slice(0, 200));

    try {
      const result = await original.apply(this, args);
      const duration = (performance.now() - start).toFixed(2);
      console.log(`[${propertyKey}] completed in ${duration}ms`);
      return result;
    } catch (error) {
      const duration = (performance.now() - start).toFixed(2);
      console.error(`[${propertyKey}] failed after ${duration}ms:`, error);
      throw error;
    }
  };

  return descriptor;
}

function RateLimit(maxCalls: number, windowMs: number) {
  const calls = new Map<string, number[]>();

  return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    const original = descriptor.value;

    descriptor.value = async function (...args: any[]) {
      const key = propertyKey;
      const now = Date.now();
      const windowCalls = calls.get(key)?.filter((t) => now - t < windowMs) || [];

      if (windowCalls.length >= maxCalls) {
        throw new Error(`Rate limit exceeded for ${key}. Max ${maxCalls} calls per ${windowMs}ms.`);
      }

      windowCalls.push(now);
      calls.set(key, windowCalls);

      return original.apply(this, args);
    };

    return descriptor;
  };
}

class JobService {
  @LogExecution
  @RateLimit(10, 60000) // Max 10 calls per minute
  async searchJobs(query: string): Promise<Job[]> {
    const response = await fetch(`/api/jobs?q=${query}`);
    return response.json();
  }

  @LogExecution
  async getJobById(id: string): Promise<Job> {
    const response = await fetch(`/api/jobs/${id}`);
    return response.json();
  }
}

The searchJobs method now has logging and rate limiting without a single line of logging or rate limiting code inside the method itself. The method only contains business logic. Cross-cutting concerns are handled by decorators. This separation is what makes TypeScript patterns that senior developers use in production so powerful. The business logic stays clean. The infrastructure concerns stay separate.

The Command Pattern in JavaScript and How to Build Undo Systems and Task Queues

Command encapsulates a request as an object, which lets you parameterize operations, queue them, log them, and support undo. Text editors, drawing apps, and any application with an undo button uses Command.

Undo/Redo System for a Rich Text Editor

interface Command {
  execute(): void;
  undo(): void;
  description: string;
}

class CommandHistory {
  private undoStack: Command[] = [];
  private redoStack: Command[] = [];

  execute(command: Command): void {
    command.execute();
    this.undoStack.push(command);
    this.redoStack = []; // Clear redo stack on new action
  }

  undo(): void {
    const command = this.undoStack.pop();
    if (command) {
      command.undo();
      this.redoStack.push(command);
    }
  }

  redo(): void {
    const command = this.redoStack.pop();
    if (command) {
      command.execute();
      this.undoStack.push(command);
    }
  }

  canUndo(): boolean {
    return this.undoStack.length > 0;
  }

  canRedo(): boolean {
    return this.redoStack.length > 0;
  }
}

// Concrete commands
class InsertTextCommand implements Command {
  description: string;

  constructor(
    private document: TextDocument,
    private position: number,
    private text: string
  ) {
    this.description = `Insert "${text.slice(0, 20)}${text.length > 20 ? "..." : ""}"`;
  }

  execute(): void {
    this.document.insertAt(this.position, this.text);
  }

  undo(): void {
    this.document.deleteRange(this.position, this.position + this.text.length);
  }
}

class DeleteTextCommand implements Command {
  description: string;
  private deletedText: string = "";

  constructor(
    private document: TextDocument,
    private start: number,
    private end: number
  ) {
    this.description = `Delete range [${start}:${end}]`;
  }

  execute(): void {
    this.deletedText = this.document.getRange(this.start, this.end);
    this.document.deleteRange(this.start, this.end);
  }

  undo(): void {
    this.document.insertAt(this.start, this.deletedText);
  }
}

class FormatTextCommand implements Command {
  description: string;
  private previousFormat: TextFormat | null = null;

  constructor(
    private document: TextDocument,
    private start: number,
    private end: number,
    private format: TextFormat
  ) {
    this.description = `Format [${start}:${end}] as ${format.type}`;
  }

  execute(): void {
    this.previousFormat = this.document.getFormat(this.start, this.end);
    this.document.applyFormat(this.start, this.end, this.format);
  }

  undo(): void {
    if (this.previousFormat) {
      this.document.applyFormat(this.start, this.end, this.previousFormat);
    }
  }
}

// Usage
const history = new CommandHistory();

history.execute(new InsertTextCommand(doc, 0, "Hello World"));
history.execute(new FormatTextCommand(doc, 0, 5, { type: "bold" }));
history.execute(new InsertTextCommand(doc, 11, "!"));

history.undo(); // Removes "!"
history.undo(); // Removes bold formatting
history.redo(); // Reapplies bold formatting

The Command pattern appears in system design interviews when candidates are asked to design collaborative editing tools, form builders with undo, or task queuing systems. The key insight interviewers look for is that you store the inverse operation (undo) alongside the forward operation (execute), and that the command history is a stack.

The Mediator Pattern in JavaScript and How to Coordinate Complex Component Interactions

Mediator defines an object that encapsulates how a set of objects interact. Instead of components communicating directly with each other (which creates tight coupling), they communicate through a mediator. Chat rooms, air traffic control, and form validation orchestrators are classic Mediator examples.

Form Validation Mediator That Coordinates Field Dependencies

interface FormField {
  name: string;
  value: unknown;
  isValid: boolean;
  errors: string[];
}

type ValidationRule = (
  value: unknown,
  allFields: Map<string, FormField>
) => string | null;

class FormMediator {
  private fields = new Map<string, FormField>();
  private rules = new Map<string, ValidationRule[]>();
  private dependencies = new Map<string, Set<string>>();
  private listeners = new Set<(fields: Map<string, FormField>) => void>();

  registerField(name: string, initialValue: unknown = ""): void {
    this.fields.set(name, {
      name,
      value: initialValue,
      isValid: true,
      errors: [],
    });
  }

  addRule(fieldName: string, rule: ValidationRule): void {
    const existing = this.rules.get(fieldName) || [];
    this.rules.set(fieldName, [...existing, rule]);
  }

  addDependency(fieldName: string, dependsOn: string): void {
    const deps = this.dependencies.get(fieldName) || new Set();
    deps.add(dependsOn);
    this.dependencies.set(fieldName, deps);
  }

  updateField(name: string, value: unknown): void {
    const field = this.fields.get(name);
    if (!field) return;

    field.value = value;
    this.validateField(name);

    // Re-validate dependent fields
    for (const [fieldName, deps] of this.dependencies) {
      if (deps.has(name)) {
        this.validateField(fieldName);
      }
    }

    this.notifyListeners();
  }

  private validateField(name: string): void {
    const field = this.fields.get(name);
    const rules = this.rules.get(name) || [];
    if (!field) return;

    field.errors = rules
      .map((rule) => rule(field.value, this.fields))
      .filter((error): error is string => error !== null);

    field.isValid = field.errors.length === 0;
  }

  onChange(listener: (fields: Map<string, FormField>) => void): () => void {
    this.listeners.add(listener);
    return () => this.listeners.delete(listener);
  }

  private notifyListeners(): void {
    this.listeners.forEach((listener) => listener(this.fields));
  }

  isFormValid(): boolean {
    return Array.from(this.fields.values()).every((f) => f.isValid);
  }
}

// Usage: job posting form where salary range depends on currency
const form = new FormMediator();

form.registerField("currency", "USD");
form.registerField("salaryMin", 0);
form.registerField("salaryMax", 0);

form.addRule("salaryMin", (value) =>
  typeof value === "number" && value > 0 ? null : "Minimum salary must be positive"
);

form.addRule("salaryMax", (value, fields) => {
  const min = fields.get("salaryMin")?.value as number;
  return typeof value === "number" && value > min
    ? null
    : "Maximum salary must be greater than minimum";
});

form.addDependency("salaryMax", "salaryMin");

form.updateField("salaryMin", 80000); // Validates salaryMin AND salaryMax

The fields do not know about each other. The Mediator knows the relationships and coordinates validation. Adding a new field with dependencies on existing fields requires registering it with the mediator, not modifying the existing fields. This is the pattern that makes complex forms manageable instead of becoming a web of interdependent state updates.

The Module Pattern in 2026 and Why ES Modules Made It Native

The Module pattern restricts access to internal state and exposes only a public API. Before ES modules, JavaScript developers used IIFEs (Immediately Invoked Function Expressions) to create private scope. Now, ES modules provide this natively. Every file is a module. Anything not exported is private. This is so fundamental that most developers do not realize they are using the Module pattern every time they write an export statement.

// services/analytics.ts - Module pattern via ES modules
// These are private - not exported, not accessible outside this file
let sessionId: string | null = null;
let eventQueue: AnalyticsEvent[] = [];
let flushInterval: ReturnType<typeof setInterval> | null = null;

function generateSessionId(): string {
  return `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
}

async function flush(): Promise<void> {
  if (eventQueue.length === 0) return;

  const events = [...eventQueue];
  eventQueue = [];

  await fetch("/api/analytics", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ sessionId, events }),
  });
}

// These are public - the module's API
export function initAnalytics(): void {
  sessionId = generateSessionId();
  flushInterval = setInterval(flush, 10000);
}

export function trackEvent(name: string, properties?: Record<string, unknown>): void {
  eventQueue.push({
    name,
    properties: properties || {},
    timestamp: Date.now(),
  });
}

export function trackPageView(path: string): void {
  trackEvent("page_view", { path });
}

export function shutdown(): void {
  if (flushInterval) clearInterval(flushInterval);
  flush();
}

The sessionId, eventQueue, flush internals are completely hidden. External code can only call initAnalytics, trackEvent, trackPageView, and shutdown. This is encapsulation through the language itself, not through convention. The module boundary is the privacy boundary.

The Iterator Pattern and Async Iterators for Processing Large Data Sets

Iterator provides a way to access elements of a collection sequentially without exposing the underlying representation. JavaScript's for...of loop, spread operator, and Array.from all work because of the Iterator protocol. In 2026, async iterators are the pattern that matters most because they enable processing data streams without loading everything into memory.

Async Iterator for Paginated API Responses

async function* fetchAllJobs(
  baseUrl: string,
  pageSize: number = 50
): AsyncGenerator<Job> {
  let page = 1;
  let hasMore = true;

  while (hasMore) {
    const response = await fetch(
      `${baseUrl}/jobs?page=${page}&limit=${pageSize}`
    );
    const data: { jobs: Job[]; totalPages: number } = await response.json();

    for (const job of data.jobs) {
      yield job;
    }

    hasMore = page < data.totalPages;
    page++;
  }
}

// Process all jobs without loading them all into memory
for await (const job of fetchAllJobs("https://api.jsgurujobs.com")) {
  if (job.salary > 150000 && job.remote) {
    await notifySubscribers(job);
  }
}

// Or collect a specific subset
async function getHighPayingRemoteJobs(limit: number): Promise<Job[]> {
  const results: Job[] = [];

  for await (const job of fetchAllJobs("https://api.jsgurujobs.com")) {
    if (job.salary > 150000 && job.remote) {
      results.push(job);
      if (results.length >= limit) break; // Stop pagination early
    }
  }

  return results;
}

The break statement stops the iterator, which means it stops making API calls. If you find 10 matching jobs on page 2 of 50 pages, you do not fetch pages 3 through 50. This lazy evaluation is what makes async iterators powerful for large data sets. The consumer controls how much data flows through the pipeline.

How to Use Design Patterns in Technical Interviews Without Sounding Like a Textbook

Interviewers do not want you to recite pattern definitions. They want you to recognize when a pattern applies and implement it naturally. Here is how patterns appear in the three most common interview formats.

In the system design round, when asked "design a notification system," the answer uses Observer for event subscription, Strategy for notification channels (email, push, SMS), Factory for creating notification objects from templates, and Command for queuing and retrying failed notifications. You do not say "I will use the Observer pattern." You describe the event subscription system and the interviewer recognizes the pattern.

In the coding round, patterns appear as refactoring opportunities. You receive a function with a 200-line switch statement handling different file formats. The right move is Strategy. You receive a class that creates different database connections based on config. The right move is Factory. You receive a function that directly calls three different analytics providers. The right move is Adapter.

In the take-home assignment, patterns appear in how you structure the project. A well-structured take-home uses the Module pattern for clear boundaries between features, the Observer pattern for component communication, and the Factory pattern for test data generation. Interviewers read the code structure before they read the code. A project with clear patterns signals senior thinking. A project where everything is in one file signals the opposite.

The developers who earn $165,000 are not smarter than the ones who earn $95,000. They have internalized these patterns to the point where they do not think about them. They just write code that way. The pattern becomes invisible because it is the natural way to solve the problem. That is the goal. Not memorizing twelve pattern names, but building twelve instincts that make your code better without conscious effort.

If you want to track how companies test for architectural thinking in JavaScript interviews, I share market data and interview patterns weekly at jsgurujobs.com.

Frequently Asked Questions About JavaScript Design Patterns in 2026

Do I Need to Know All 12 Patterns for a Senior JavaScript Interview

No. Master Observer, Strategy, Factory, and Proxy and you will handle 80% of interview scenarios. These four appear most frequently because they solve the problems that come up in every application: event handling, conditional logic, object creation, and behavior interception. The remaining eight patterns are valuable for specific domains like undo systems (Command), complex forms (Mediator), and data streaming (Iterator).

Are Design Patterns Still Relevant When AI Can Generate Code

More relevant than ever. AI generates code that works but lacks architectural coherence. A codebase with 10,000 lines of AI-generated code and no patterns becomes unmaintainable within months. The developer who recognizes that the growing switch statement needs Strategy, or that the tightly coupled services need Observer, is the developer who keeps the codebase healthy as AI accelerates code production.

What Is the Difference Between Design Patterns in JavaScript and in Java or C#

JavaScript patterns are more flexible because the language supports both object-oriented and functional programming. The Observer pattern in Java requires interfaces and abstract classes. In JavaScript, it is a function that takes a callback. The Strategy pattern in Java requires a class hierarchy. In JavaScript, it can be a plain object with functions, a Map of functions, or even a higher-order function. The concepts are identical but the implementation is lighter.

Which Design Pattern Should I Learn First as a Mid-Level Developer

Start with Observer. It is the foundation of event-driven programming, React state management, Node.js EventEmitter, and browser DOM events. Once you understand Observer deeply, Strategy and Factory follow naturally because they address the same core principle of decoupling code that changes from code that stays the same.

 

Related articles

career 1 month ago

The Developer Shortage Gets 40% Worse in 2026: $200K+ Opportunities From Hiring Crisis

The global developer shortage that companies hoped would resolve through economic corrections and layoffs instead intensified dramatically in 2026, creating a crisis 40% worse than 2025 according to multiple labor market analyses. The United States alone faces a 1.2 million software developer deficit by year end, while demand accelerates faster than new developers enter the workforce. Three converging forces created this perfect storm that's reshaping compensation and career trajectories: AI and machine learning expansion tripled demand for developers who can implement generative AI features and integrate language models into existing applications,

John Smith Read more
Vibe Coding Is Changing How JavaScript Developers Work in 2026 and Most of Them Are Doing It Wrong
career 2 weeks ago

Vibe Coding Is Changing How JavaScript Developers Work in 2026 and Most of Them Are Doing It Wrong

A single developer shipped a complete SaaS application in eleven days last month. Authentication, payments, dashboard, admin panel, API integrations, responsive design, deployment. The kind of product that would have taken a five person team two to three months in 2023. He did it by describing features in plain English and letting AI write virtually all of the code.

John Smith Read more
Startup vs Big Tech vs Agency and Where JavaScript Developers Actually Earn More and Grow Faster
career 1 month ago

Startup vs Big Tech vs Agency and Where JavaScript Developers Actually Earn More and Grow Faster

Every JavaScript developer faces this decision at some point. You have built up your skills in React, Node.js, TypeScript, and modern frameworks. You have shipped products and solved real problems. Now comes the question that keeps appearing in every career conversation, every salary negotiation, and every late night scroll through job boards.

John Smith Read more