Module Pattern Challenge

Module Pattern — Code Challenges


Challenge 1 — The Leaky Counter

The Code

Paste this into your REPL and call a few functions.

let count = 0;
let step = 1;

function increment() { count += step; }
function decrement() { count -= step; }
function setStep(n) { step = n; }
function reset() { count = 0; }
function getCount() { return count; }

What’s Wrong

Run these lines and observe:

increment();
increment();
console.log(getCount()); // 2 — works fine

// But nothing prevents this:
count = 9999;
step = -500;
console.log(getCount()); // 9999 — invariants broken

// And there's no way to run two independent counters.

Three problems in one:

  1. count and step sit in the outer scope — any code that can see them can overwrite them
  2. setStep(0) or setStep("fast") silently corrupts the counter; no validation exists
  3. There is no way to create a second counter without duplicating every variable by hand

Your Task

Refactor this so that:

  • count and step are private — not accessible from outside
  • setStep(n) throws a RangeError if n is not a positive integer
  • Each call to your factory creates an independent counter instance

Target interface:

const a = createCounter();
const b = createCounter({ step: 5 });

a.increment();
a.increment();
console.log(a.getCount()); // 2
console.log(b.getCount()); // 0 — independent

b.increment();
console.log(b.getCount()); // 5

a.setStep(10);
a.increment();
console.log(a.getCount()); // 12

Hints

#1 — What structure to reach for

The core move is wrapping the state inside a function scope instead of leaving it floating at the top level. A function’s local variables cannot be read or written from outside it.

Write a factory function (createCounter) that:

  1. Declares count and step as local variables inside it
  2. Returns an object whose methods close over those variables

The returned object is your public API. Callers get the methods — they can never touch the raw variables directly.

#2 — Sketch the shape
function createCounter(options) {
  // private state — lives only inside this function
  let count = ___
  let _step = ___

  return {
    increment()  { ... },
    decrement()  { ... },
    setStep(n)   { /* validate n, then assign */ },
    reset()      { ... },
    getCount()   { ... },
  };
}

For setStep, ask: what makes a valid step? What should you throw if the value is bad?

#3 — Pseudocode walkthrough
function createCounter({ step = 1 } = {}) {
  let count = 0
  let _step = step     // private, different name avoids shadowing the param

  function validateStep(n):
    if n is not a positive integerthrow RangeError("step must be a positive integer")

  validateStep(_step)  // catch a bad initial step too

  return {
    increment()   → count += _step
    decrement()   → count -= _step
    setStep(n)    → validateStep(n), then _step = n
    reset()       → count = 0
    getCount()    → return count
  }
}

The key: count and _step are not properties on the returned object. They live only inside the function scope — callers can’t reach them.


Solution

View solution
function createCounter({ step = 1 } = {}) {
  let count = 0;
  let _step = step;

  function validateStep(n) {
    if (!Number.isInteger(n) || n < 1) {
      throw new RangeError(`step must be a positive integer, got: ${n}`);
    }
  }

  validateStep(_step);

  return {
    increment() { count += _step; },
    decrement() { count -= _step; },
    setStep(n) {
      validateStep(n);
      _step = n;
    },
    reset() { count = 0; },
    getCount() { return count; },
  };
}

// --- Verify ---
const a = createCounter();
const b = createCounter({ step: 5 });

a.increment();
a.increment();
console.log(a.getCount()); // 2
console.log(b.getCount()); // 0

b.increment();
console.log(b.getCount()); // 5

a.setStep(10);
a.increment();
console.log(a.getCount()); // 12

// Validation fires:
try { a.setStep(0); } catch (e) { console.log(e.message); }
// "step must be a positive integer, got: 0"

// Direct mutation is gone:
console.log(a.count);  // undefined
console.log(a._step);  // undefined

What changed:

  • count and _step are local to createCounter — they never appear on the returned object
  • validateStep is also private — an internal detail callers don’t see
  • Every createCounter() call produces its own independent closure with its own state

Concepts at work: The mechanism is a closure — inner functions retain access to variables in the outer scope even after that scope has returned. This is what makes encapsulation possible without a class. The factory pattern (returning a new object per call) gives you independent instances, each with their own private heap.


Challenge 2 — The Broken Cart

The Code

This cart works at first glance, but it has a structural flaw. Paste it in and poke at it.

let items = [];
let discount = 0;

function addItem(name, price) {
  items.push({ name, price });
}

function removeItem(name) {
  items = items.filter(item => item.name !== name);
}

function applyDiscount(percent) {
  discount = percent;
}

function getTotal() {
  const subtotal = items.reduce((sum, item) => sum + item.price, 0);
  return subtotal * (1 - discount / 100);
}

function getItems() {
  return items;
}

What’s Wrong

Run this sequence:

addItem("Coffee", 4.50);
addItem("Muffin", 3.00);

// getItems() hands back the real array — callers can mutate it directly:
const ref = getItems();
ref.push({ name: "Freebie", price: -9999 });
console.log(getTotal()); // total is now wrong — we didn't call addItem

// No validation on discount:
applyDiscount(150);  // 150% off — total goes negative, no error
applyDiscount(-10);  // negative discount — also silently accepted

Two distinct problems here:

  1. Live reference leakgetItems() returns the actual internal array. Anything holding that reference can silently corrupt cart state
  2. No input validationapplyDiscount and addItem accept any value without checking

Your Task

Refactor this as a module factory (createCart):

  • items and discount are private
  • getItems() returns a copy of the array, not the live reference
  • applyDiscount(pct) throws a RangeError if pct is not a number between 0 and 100
  • addItem(name, price) throws a RangeError if price is not a positive number
  • Each createCart() call is an independent cart

Target interface:

const cart = createCart();

cart.addItem("Coffee", 4.50);
cart.addItem("Muffin", 3.00);
console.log(cart.getTotal()); // 7.5

cart.applyDiscount(10);
console.log(cart.getTotal()); // 6.75

// Snapshot doesn't affect internal state:
const snapshot = cart.getItems();
snapshot.push({ name: "Freebie", price: -9999 });
console.log(cart.getTotal()); // still 6.75

// Validation:
try { cart.applyDiscount(150); } catch(e) { console.log(e.message); }
try { cart.addItem("Bad", -1); }  catch(e) { console.log(e.message); }

Hints

#1 — Two problems, two fixes

Problem 1 — Encapsulation: Same move as Challenge 1. Declare items and discount inside a factory function, return an object whose methods close over them. Callers only get the methods.

Problem 2 — Live reference: getItems() currently returns the internal array directly. Return a copy instead — [...items] creates a new array with the same contents. External mutations to that copy won’t reach the private array.

The validation guards are just a check-and-throw at the top of addItem and applyDiscount before any state is touched.

#2 — Sketch the shape
function createCart() {
  const items = [];   // const: the array isn't replaced, only mutated
  let discount = 0;

  return {
    addItem(name, price)    { /* validate price, then items.push(...) */ },
    removeItem(name)        { /* remove matching item in-place */ },
    applyDiscount(pct)      { /* validate 0–100, then discount = pct */ },
    getTotal()              { /* reduce + apply discount */ },
    getItems()              { /* return [...items] */ },
  };
}

items is const because you never reassign the array reference — you push into it and splice from it. discount is let because you reassign its value.

For removeItem: you can’t do items = items.filter(...) with const. What array method mutates in place?

#3 — Pseudocode walkthrough
function createCart() {
  const items = []
  let discount = 0

  return {
    addItem(name, price):
      if price is not a positive number → throw RangeError
      items.push({ name, price })

    removeItem(name):
      find index of item with matching name
      if found → items.splice(index, 1)

    applyDiscount(pct):
      if pct < 0 or pct > 100 → throw RangeError
      discount = pct

    getTotal():
      subtotal = items.reduce((sum, item) => sum + item.price, 0)
      return subtotal * (1 - discount / 100)

    getItems():
      return [...items]   ← spread creates a new array, not a reference
  }
}

Solution

View solution
function createCart() {
  const items = [];
  let discount = 0;

  return {
    addItem(name, price) {
      if (typeof price !== 'number' || price <= 0) {
        throw new RangeError(`price must be a positive number, got: ${price}`);
      }
      items.push({ name, price });
    },

    removeItem(name) {
      const index = items.findIndex(item => item.name === name);
      if (index !== -1) items.splice(index, 1);
    },

    applyDiscount(pct) {
      if (typeof pct !== 'number' || pct < 0 || pct > 100) {
        throw new RangeError(`discount must be 0–100, got: ${pct}`);
      }
      discount = pct;
    },

    getTotal() {
      const subtotal = items.reduce((sum, item) => sum + item.price, 0);
      return subtotal * (1 - discount / 100);
    },

    getItems() {
      return [...items];
    },
  };
}

// --- Verify ---
const cart = createCart();

cart.addItem("Coffee", 4.50);
cart.addItem("Muffin", 3.00);
console.log(cart.getTotal()); // 7.5

cart.applyDiscount(10);
console.log(cart.getTotal()); // 6.75

const snapshot = cart.getItems();
snapshot.push({ name: "Freebie", price: -9999 });
console.log(cart.getTotal()); // 6.75 — unaffected

cart.removeItem("Coffee");
console.log(cart.getTotal()); // 2.7

try { cart.applyDiscount(150); } catch(e) { console.log(e.message); }
// "discount must be 0–100, got: 150"

try { cart.addItem("Bad", -1); } catch(e) { console.log(e.message); }
// "price must be a positive number, got: -1"

What changed:

  • items is const — the reference is stable, only contents change via push/splice
  • getItems() spreads into a new array — external mutations can’t reach private state
  • Both guards throw before touching state, so the cart stays consistent on bad input

Concepts at work: Returning [...items] is a defensive copy — a direct application of information hiding. Callers get a snapshot of the data, not a live reference to the internal structure. Combined with the validation guards, this establishes a trust boundary: everything outside the factory depends on the public API contract, not on implementation details that could change.


When Not to Use the Module Pattern

Not every file needs a factory. Reach for something simpler when:

  • Nothing is private. A group of pure utility functions with no shared state should be plain named exports — wrapping them in a factory adds a call-site hop with no benefit.
  • One shared instance is fine. If the whole app can safely share a single state (a config object, a feature-flag map), a module-level let with accessor functions is enough. You don’t need a factory that’s only ever called once.
  • You need inheritance or shared behaviour. When multiple types share methods and you’re already reaching for extends, a class expresses that more clearly than a factory returning a plain object.
  • The “module” owns nothing. If a function doesn’t close over any state, it isn’t using the pattern — it’s just a function. Don’t dress it up.

The rule of thumb: if you can delete the factory wrapper and nothing breaks, you probably should.


What to Take Away

Before moving on, make sure you can answer these from memory:

  1. Why does putting state inside a function prevent outside access? What is the mechanism called?
  2. What’s the difference between module-level let items = [] and declaring items inside a factory function?
  3. Why does getItems() return [...items] instead of items — and what bug does that prevent?
  4. In Challenge 2, why is items declared with const even though items are added and removed from it?

Module Pattern