Skip to content

Closure Application Patterns: Best Practices in Real-world Scenarios

Overview of Closure Patterns

After mastering the basic concepts of closures, it's more important to understand how to apply closures in actual projects. Just like learning the technique of laying bricks, the next step is to learn how to build different types of buildings—from simple cottages to complex skyscrapers. This article will introduce commonly used closure application patterns in JavaScript development, all of which are time-tested and widely used best practices in real projects.

Module Pattern

The module pattern is one of the most common closure applications, using closures to create private scope and implement data encapsulation and information hiding.

Basic Module Pattern

javascript
const UserModule = (function () {
  // Private variables
  let users = [];
  let currentId = 1;

  // Private methods
  function generateId() {
    return currentId++;
  }

  function validateUser(user) {
    if (!user.name || user.name.trim() === "") {
      throw new Error("User must have a name");
    }
    if (!user.email || !isValidEmail(user.email)) {
      throw new Error("User must have a valid email");
    }
    return true;
  }

  function isValidEmail(email) {
    return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
  }

  // Public API
  return {
    createUser(userData) {
      const user = {
        id: generateId(),
        name: userData.name,
        email: userData.email,
        createdAt: new Date(),
      };

      validateUser(user);
      users.push(user);

      return user.id;
    },

    getUser(id) {
      return users.find((user) => user.id === id);
    },

    updateUser(id, updates) {
      const user = users.find((u) => u.id === id);
      if (!user) {
        throw new Error("User not found");
      }

      Object.assign(user, updates);
      validateUser(user);
      user.updatedAt = new Date();

      return user;
    },

    deleteUser(id) {
      const index = users.findIndex((u) => u.id === id);
      if (index !== -1) {
        users.splice(index, 1);
        return true;
      }
      return false;
    },

    getAllUsers() {
      return users.map((u) => ({ ...u })); // Return copy
    },
  };
})();

// Use module
const userId = UserModule.createUser({
  name: "Sarah Johnson",
  email: "[email protected]",
});

console.log(UserModule.getUser(userId));
// { id: 1, name: 'Sarah Johnson', email: '[email protected]', ... }

// Private members cannot be accessed
// console.log(users);      // ReferenceError
// UserModule.generateId(); // TypeError: not a function

Revealing Module Pattern

This is a variant of the module pattern where all methods are defined in the private scope and then exposed uniformly:

javascript
const Calculator = (function () {
  // Private state
  let history = [];
  let precision = 2;

  // Private helper functions
  function round(number) {
    return Number(number.toFixed(precision));
  }

  function recordOperation(operation, operands, result) {
    history.push({
      operation,
      operands,
      result,
      timestamp: new Date(),
    });
  }

  // Public methods (all defined in private scope)
  function add(a, b) {
    const result = round(a + b);
    recordOperation("add", [a, b], result);
    return result;
  }

  function subtract(a, b) {
    const result = round(a - b);
    recordOperation("subtract", [a, b], result);
    return result;
  }

  function multiply(a, b) {
    const result = round(a * b);
    recordOperation("multiply", [a, b], result);
    return result;
  }

  function divide(a, b) {
    if (b === 0) {
      throw new Error("Division by zero");
    }
    const result = round(a / b);
    recordOperation("divide", [a, b], result);
    return result;
  }

  function getHistory() {
    return [...history];
  }

  function clearHistory() {
    history = [];
  }

  function setPrecision(value) {
    if (value < 0 || value > 10) {
      throw new Error("Precision must be between 0 and 10");
    }
    precision = value;
  }

  // Reveal public interface
  return {
    add,
    subtract,
    multiply,
    divide,
    getHistory,
    clearHistory,
    setPrecision,
  };
})();

// Use
Calculator.add(10, 5); // 15
Calculator.multiply(3, 7); // 21
Calculator.divide(10, 3); // 3.33

console.log(Calculator.getHistory());
// [
//   { operation: 'add', operands: [10, 5], result: 15, ... },
//   { operation: 'multiply', operands: [3, 7], result: 21, ... },
//   { operation: 'divide', operands: [10, 3], result: 3.33, ... }
// ]

Module Enhancement and Extension

Modules can be enhanced by passing the module itself:

javascript
const TaskManager = (function () {
  let tasks = [];

  return {
    addTask(task) {
      tasks.push(task);
    },

    getTasks() {
      return [...tasks];
    },
  };
})();

// Extend module
const TaskManager_Extended = (function (module) {
  // Preserve original private state
  let filterCache = new Map();

  // Add new methods
  module.filterTasks = function (predicate) {
    const key = predicate.toString();

    if (filterCache.has(key)) {
      return filterCache.get(key);
    }

    const result = this.getTasks().filter(predicate);
    filterCache.set(key, result);

    return result;
  };

  module.clearCache = function () {
    filterCache.clear();
  };

  return module;
})(TaskManager);

// Use extended module
TaskManager_Extended.addTask({ title: "Task 1", completed: false });
TaskManager_Extended.addTask({ title: "Task 2", completed: true });

const incompleteTasks = TaskManager_Extended.filterTasks((t) => !t.completed);
console.log(incompleteTasks); // [{ title: 'Task 1', completed: false }]

Singleton Pattern

Using closures to ensure a class has only one instance:

javascript
const DatabaseConnection = (function () {
  let instance;

  function createConnection(config) {
    // Private state
    const connectionId = Math.random().toString(36).substr(2, 9);
    const createdAt = new Date();
    let isConnected = false;
    let queryCount = 0;

    // Private methods
    function log(message) {
      console.log(`[${connectionId}] ${message}`);
    }

    // Public interface
    return {
      connect() {
        if (isConnected) {
          log("Already connected");
          return;
        }

        log(`Connecting to ${config.host}:${config.port}`);
        isConnected = true;
      },

      disconnect() {
        if (!isConnected) {
          log("Already disconnected");
          return;
        }

        log("Disconnecting...");
        isConnected = false;
      },

      query(sql) {
        if (!isConnected) {
          throw new Error("Not connected to database");
        }

        queryCount++;
        log(`Executing query #${queryCount}: ${sql}`);

        // Simulate query
        return { success: true, rows: [] };
      },

      getStats() {
        return {
          connectionId,
          createdAt,
          isConnected,
          queryCount,
        };
      },
    };
  }

  return {
    getInstance(config) {
      if (!instance) {
        instance = createConnection(config);
      }
      return instance;
    },

    resetInstance() {
      instance = null;
    },
  };
})();

// Use singleton
const db1 = DatabaseConnection.getInstance({
  host: "localhost",
  port: 5432,
});

const db2 = DatabaseConnection.getInstance({
  host: "remotehost", // This config will be ignored
  port: 3306,
});

console.log(db1 === db2); // true - same instance

db1.connect();
db1.query("SELECT * FROM users");

console.log(db1.getStats());
console.log(db2.getStats()); // Same stats

Factory Pattern

Using closures to create object factories:

javascript
function createUserFactory(defaultRole) {
  // Factory-level private state
  let userCount = 0;
  const createdUsers = [];

  // Factory-level private methods
  function generateUsername(name) {
    const baseName = name.toLowerCase().replace(/\s+/g, "");
    return `${baseName}_${Date.now()}`;
  }

  function trackUser(user) {
    createdUsers.push({
      id: user.id,
      createdAt: new Date(),
    });
  }

  // Return factory function
  return function createUser(name, customRole) {
    userCount++;

    const user = {
      id: userCount,
      name,
      username: generateUsername(name),
      role: customRole || defaultRole,

      // Instance methods (each user has them)
      getProfile() {
        return {
          id: this.id,
          name: this.name,
          username: this.username,
          role: this.role,
        };
      },

      hasPermission(permission) {
        const rolePermissions = {
          admin: ["read", "write", "delete"],
          user: ["read"],
          guest: [],
        };

        return rolePermissions[this.role]?.includes(permission) || false;
      },
    };

    trackUser(user);

    return user;
  };
}

// Create different factories
const createAdmin = createUserFactory("admin");
const createRegularUser = createUserFactory("user");

const admin = createAdmin("John Smith");
const user1 = createRegularUser("Sarah Wilson");
const user2 = createRegularUser("Michael Brown");

console.log(admin.hasPermission("delete")); // true
console.log(user1.hasPermission("delete")); // false
console.log(user2.hasPermission("read")); // true

Method Chaining Pattern

Implement method chaining by returning the function itself:

javascript
function createQueryBuilder(tableName) {
  // Private state
  let query = {
    table: tableName,
    select: [],
    where: [],
    orderBy: [],
    limit: null,
  };

  const builder = {
    SELECT(...fields) {
      query.select.push(...fields);
      return this; // Return self for chaining
    },

    WHERE(condition) {
      query.where.push(condition);
      return this;
    },

    AND(condition) {
      if (query.where.length === 0) {
        throw new Error("Cannot use AND without WHERE");
      }
      query.where.push(`AND ${condition}`);
      return this;
    },

    OR(condition) {
      if (query.where.length === 0) {
        throw new Error("Cannot use OR without WHERE");
      }
      query.where.push(`OR ${condition}`);
      return this;
    },

    ORDER_BY(field, direction = "ASC") {
      query.orderBy.push(`${field} ${direction}`);
      return this;
    },

    LIMIT(count) {
      query.limit = count;
      return this;
    },

    build() {
      let sql = "SELECT ";
      sql += query.select.length > 0 ? query.select.join(", ") : "*";
      sql += ` FROM ${query.table}`;

      if (query.where.length > 0) {
        sql += " WHERE " + query.where.join(" ");
      }

      if (query.orderBy.length > 0) {
        sql += " ORDER BY " + query.orderBy.join(", ");
      }

      if (query.limit) {
        sql += ` LIMIT ${query.limit}`;
      }

      return sql;
    },

    reset() {
      query = {
        table: tableName,
        select: [],
        where: [],
        orderBy: [],
        limit: null,
      };
      return this;
    },
  };

  return builder;
}

// Use method chaining
const userQuery = createQueryBuilder("users")
  .SELECT("id", "name", "email")
  .WHERE("age > 18")
  .AND('status = "active"')
  .OR('role = "admin"')
  .ORDER_BY("created_at", "DESC")
  .LIMIT(10)
  .build();

console.log(userQuery);
// SELECT id, name, email FROM users
// WHERE age > 18 AND status = "active" OR role = "admin"
// ORDER BY created_at DESC LIMIT 10

Currying Pattern

Convert multi-parameter functions to a series of single-parameter functions:

javascript
// General currying function
function curry(fn) {
  return function curried(...args) {
    if (args.length >= fn.length) {
      return fn.apply(this, args);
    }

    return function (...nextArgs) {
      return curried.apply(this, [...args, ...nextArgs]);
    };
  };
}

// Practical application examples
const log = curry(function (level, timestamp, message) {
  console.log(`[${level}] ${timestamp}: ${message}`);
});

// Create specialized logging functions
const error = log("ERROR");
const errorNow = error(new Date().toISOString());

errorNow("Database connection failed");
errorNow("Invalid user input");

const info = log("INFO");
const infoNow = info(new Date().toISOString());

infoNow("User logged in");
infoNow("Task completed");

// Can also pass all parameters at once
log("WARN", new Date().toISOString(), "Cache is full");

// More practical example: create configured functions
const createEmailValidator = curry(function (domain, minLength, email) {
  if (email.length < minLength) {
    return false;
  }
  return email.endsWith(`@${domain}`);
});

const isCompanyEmail = createEmailValidator("company.com")(5);

console.log(isCompanyEmail("[email protected]")); // true
console.log(isCompanyEmail("[email protected]")); // false (too short)
console.log(isCompanyEmail("[email protected]")); // false (domain mismatch)

Partial Application Pattern

Fix part of a function's parameters:

javascript
function partial(fn, ...fixedArgs) {
  return function (...remainingArgs) {
    return fn.apply(this, [...fixedArgs, ...remainingArgs]);
  };
}

// Usage examples
function createNotification(type, title, message, options) {
  return {
    type,
    title,
    message,
    timestamp: new Date(),
    ...options,
  };
}

// Create specific type notification functions
const createError = partial(createNotification, "error");
const createWarning = partial(createNotification, "warning");
const createSuccess = partial(createNotification, "success");

// Create more specific functions
const createErrorWithTitle = partial(createError, "Error Occurred");

// Use
const notification1 = createErrorWithTitle("Failed to save data", {
  dismissible: true,
});

const notification2 = createSuccess(
  "Operation Complete",
  "Data saved successfully",
  { autoClose: 3000 }
);

console.log(notification1);
// { type: 'error', title: 'Error Occurred', message: 'Failed to save data', ... }

console.log(notification2);
// { type: 'success', title: 'Operation Complete', ... }

Decorator Pattern

Using closures to wrap and enhance existing functions:

javascript
// Performance measurement decorator
function measurePerformance(fn, label) {
  return function (...args) {
    const start = performance.now();
    const result = fn.apply(this, args);
    const end = performance.now();

    console.log(`${label || fn.name} took ${(end - start).toFixed(2)}ms`);

    return result;
  };
}

// Cache decorator
function memoize(fn) {
  const cache = new Map();

  return function (...args) {
    const key = JSON.stringify(args);

    if (cache.has(key)) {
      console.log("Returning cached result");
      return cache.get(key);
    }

    const result = fn.apply(this, args);
    cache.set(key, result);

    return result;
  };
}

// Retry decorator
function retry(fn, maxAttempts = 3, delay = 1000) {
  return async function (...args) {
    let lastError;

    for (let attempt = 1; attempt <= maxAttempts; attempt++) {
      try {
        return await fn.apply(this, args);
      } catch (error) {
        lastError = error;
        console.log(`Attempt ${attempt} failed: ${error.message}`);

        if (attempt < maxAttempts) {
          await new Promise((resolve) => setTimeout(resolve, delay));
        }
      }
    }

    throw new Error(
      `Failed after ${maxAttempts} attempts: ${lastError.message}`
    );
  };
}

// Log decorator
function logCalls(fn) {
  return function (...args) {
    console.log(`Calling ${fn.name} with args:`, args);
    const result = fn.apply(this, args);
    console.log(`${fn.name} returned:`, result);
    return result;
  };
}

// Use decorators
function fibonacci(n) {
  if (n <= 1) return n;
  return fibonacci(n - 1) + fibonacci(n - 2);
}

// Combine multiple decorators
const optimizedFib = memoize(measurePerformance(fibonacci, "Fibonacci"));

console.log(optimizedFib(40)); // First time, calculate and record time
console.log(optimizedFib(40)); // Second time, return from cache

Observer Pattern

Using closures to implement publish-subscribe pattern:

javascript
function createEventEmitter() {
  // Private state
  const events = new Map();
  const onceListeners = new WeakMap();

  return {
    on(event, callback) {
      if (!events.has(event)) {
        events.set(event, []);
      }
      events.get(event).push(callback);

      // Return unsubscribe function
      return () => this.off(event, callback);
    },

    once(event, callback) {
      const wrapped = (...args) => {
        callback.apply(this, args);
        this.off(event, wrapped);
      };

      onceListeners.set(wrapped, callback);
      this.on(event, wrapped);

      return () => this.off(event, wrapped);
    },

    off(event, callback) {
      if (!events.has(event)) return;

      const callbacks = events.get(event);
      const index = callbacks.findIndex((cb) => {
        return cb === callback || onceListeners.get(cb) === callback;
      });

      if (index !== -1) {
        callbacks.splice(index, 1);
      }
    },

    emit(event, ...args) {
      if (!events.has(event)) return;

      const callbacks = [...events.get(event)];
      callbacks.forEach((callback) => {
        callback.apply(this, args);
      });
    },

    removeAllListeners(event) {
      if (event) {
        events.delete(event);
      } else {
        events.clear();
      }
    },

    listenerCount(event) {
      return events.has(event) ? events.get(event).length : 0;
    },
  };
}

// Use
const emitter = createEventEmitter();

// Subscribe to events
const unsubscribe = emitter.on("user:login", (user) => {
  console.log(`User logged in: ${user.name}`);
});

emitter.once("app:ready", () => {
  console.log("App is ready!");
});

// Trigger events
emitter.emit("user:login", { name: "Sarah" });
emitter.emit("user:login", { name: "John" });

emitter.emit("app:ready"); // Triggers only once
emitter.emit("app:ready"); // Won't trigger

// Unsubscribe
unsubscribe();
emitter.emit("user:login", { name: "Michael" }); // Won't trigger

State Machine Pattern

Using closures to manage state transitions:

javascript
function createStateMachine(initialState, transitions) {
  let currentState = initialState;
  const listeners = new Map();

  function canTransition(toState) {
    const allowedTransitions = transitions[currentState];
    return allowedTransitions && allowedTransitions.includes(toState);
  }

  function notifyListeners(from, to) {
    const callbacks = listeners.get("transition") || [];
    callbacks.forEach((cb) => cb({ from, to, timestamp: new Date() }));
  }

  return {
    getState() {
      return currentState;
    },

    transition(toState) {
      if (!canTransition(toState)) {
        throw new Error(
          `Invalid transition from "${currentState}" to "${toState}"`
        );
      }

      const from = currentState;
      currentState = toState;
      notifyListeners(from, to);

      return currentState;
    },

    canTransitionTo(toState) {
      return canTransition(toState);
    },

    onTransition(callback) {
      if (!listeners.has("transition")) {
        listeners.set("transition", []);
      }
      listeners.get("transition").push(callback);

      return () => {
        const callbacks = listeners.get("transition");
        const index = callbacks.indexOf(callback);
        if (index !== -1) {
          callbacks.splice(index, 1);
        }
      };
    },

    getAvailableTransitions() {
      return transitions[currentState] || [];
    },
  };
}

// Use state machine
const orderStateMachine = createStateMachine("pending", {
  pending: ["processing", "cancelled"],
  processing: ["shipped", "cancelled"],
  shipped: ["delivered", "returned"],
  delivered: ["returned"],
  cancelled: [],
  returned: [],
});

// Listen for state changes
orderStateMachine.onTransition((transition) => {
  console.log(`Order ${transition.from} -> ${transition.to}`);
});

// Execute state transitions
console.log(orderStateMachine.getState()); // "pending"
orderStateMachine.transition("processing");
orderStateMachine.transition("shipped");
orderStateMachine.transition("delivered");

// Check available transitions
console.log(orderStateMachine.getAvailableTransitions()); // ['returned']

// Try invalid transition
try {
  orderStateMachine.transition("processing"); // Error!
} catch (error) {
  console.error(error.message);
}

Command Pattern

Encapsulate operations as objects, supporting undo/redo:

javascript
function createCommandManager() {
  const history = [];
  let currentIndex = -1;

  return {
    execute(command) {
      // Execute command
      command.execute();

      // Clear history after current position
      history.splice(currentIndex + 1);

      // Add to history
      history.push(command);
      currentIndex++;
    },

    undo() {
      if (currentIndex >= 0) {
        const command = history[currentIndex];
        command.undo();
        currentIndex--;
        return true;
      }
      return false;
    },

    redo() {
      if (currentIndex < history.length - 1) {
        currentIndex++;
        const command = history[currentIndex];
        command.execute();
        return true;
      }
      return false;
    },

    canUndo() {
      return currentIndex >= 0;
    },

    canRedo() {
      return currentIndex < history.length - 1;
    },

    getHistory() {
      return history.map((cmd, index) => ({
        type: cmd.type,
        isCurrent: index === currentIndex,
      }));
    },
  };
}

// Create command factory
function createCommand(type, execute, undo) {
  return { type, execute, undo };
}

// Usage example: text editor
const editor = {
  content: "",

  insert(text) {
    this.content += text;
  },

  delete(length) {
    this.content = this.content.slice(0, -length);
  },

  getContent() {
    return this.content;
  },
};

const commandManager = createCommandManager();

// Create insert command
function createInsertCommand(text) {
  return createCommand(
    "insert",
    () => editor.insert(text),
    () => editor.delete(text.length)
  );
}

// Create delete command
function createDeleteCommand(length) {
  let deletedText = "";

  return createCommand(
    "delete",
    () => {
      deletedText = editor.content.slice(-length);
      editor.delete(length);
    },
    () => editor.insert(deletedText)
  );
}

// Use commands
commandManager.execute(createInsertCommand("Hello"));
console.log(editor.getContent()); // "Hello"

commandManager.execute(createInsertCommand(" World"));
console.log(editor.getContent()); // "Hello World"

commandManager.execute(createDeleteCommand(6));
console.log(editor.getContent()); // "Hello"

// Undo
commandManager.undo();
console.log(editor.getContent()); // "Hello World"

// Redo
commandManager.redo();
console.log(editor.getContent()); // "Hello"

console.log(commandManager.getHistory());
// [
//   { type: 'insert', isCurrent: false },
//   { type: 'insert', isCurrent: false },
//   { type: 'delete', isCurrent: true }
// ]

Summary

Closure application patterns showcase JavaScript's power and flexibility. Through these patterns, we can:

  1. Module Pattern: Create private scope, implement data encapsulation
  2. Singleton Pattern: Ensure only one instance exists
  3. Factory Pattern: Standardized way to create objects
  4. Method Chaining: Provide fluent API interfaces
  5. Currying: Implement function partial application and reuse
  6. Decorators: Enhance functionality without modifying original functions
  7. Observer: Implement event-driven architecture
  8. State Machine: Manage complex state transitions
  9. Command Pattern: Encapsulate operations, support undo/redo

These patterns are not just theoretical knowledge but best practices proven in real projects. Mastering them can make your code more elegant, maintainable, and extensible. In the next article, we'll explore memory leak issues that closures might cause, and how to effectively avoid and solve these problems.