Module: JavaScript
JavaScript·066·4 MIN READ

066: Core JavaScript Practice: Shopping Cart Logic

TOPICS COVERED: Core JavaScript Practice: Shopping Cart Logic

Learning outcomes

By the end of this lesson, you can:

  • model cart lines separately from catalog products;
  • decompose add, quantity, remove, validation, discount, and total logic;
  • calculate line totals, subtotal, discount, and final total correctly;
  • prevent invalid quantities and unavailable stock from entering checkout state;
  • update cart arrays in an immutable style without overusing reduce().

Retrieval warm-up

  1. Why should a cart line store a product ID rather than duplicate every product field?
  2. Which method answers whether any cart item is invalid?
  3. Which method naturally sums line totals?
  4. Why must update code use ??, not ||, when zero has meaning?

Expected ideas: one source of catalog truth; some() (or collect failures with filter()); reduce(); || replaces zero.

Vocabulary

  • Cart line: product identifier plus selected quantity (course term).
  • Line total: current product price multiplied by cart quantity (course term).
  • Subtotal: sum before discounts or other adjustments (course term).
  • Discount: amount subtracted according to a business rule (course term).
  • Final total: subtotal minus discount (and plus taxes/shipping if specified) (course term).
  • Coupon: code selecting a discount rule (course term).
  • Decomposition: splitting one problem into focused functions (course term).
  • Source of truth: authoritative location for a value, such as catalog price (course term).
  • Cart total (official): "A cart total is a derived value computed from line items (price × quantity) plus adjustments." — Source: MDN: Working with objects — Derived data
  • Pure function (cart): "A pure function returns the same output for the same input and has no side effects." — Source: MDN: Functions guide

Beginner mental model

Keep catalog facts and customer choices separate:

text
catalog product: id, name, price, stock
cart line:       productId, quantity

At calculation time, join a cart line to its product by ID. This ensures current price/name come from the catalog and quantity comes from the cart. It also means a deleted product must be handled explicitly.

Design functions in layers:

text
lookup product
    -> validate requested quantity
    -> update cart
    -> derive detailed lines
    -> calculate subtotal
    -> calculate discount
    -> produce checkout summary

Do not build one giant reduce() that validates, looks up, discounts, and formats. reduce() is excellent for a clear numeric sum. map(), find(), some(), every(), and ordinary functions express other intent better.

Worked beginner example: cart operations

js
const products = [
  { id: "p1", name: "Notebook", price: 4, stock: 12 },
  { id: "p3", name: "Water Bottle", price: 16, stock: 7 },
  { id: "p4", name: "Backpack", price: 45, stock: 3 },
];

function requireProduct(products, productId) {
  const product = products.find((item) => item.id === productId);
  if (!product) {
    throw new Error(`Product ${productId} was not found`);
  }
  return product;
}

function validateQuantity(quantity, stock) {
  if (!Number.isInteger(quantity) || quantity < 1) {
    throw new RangeError("Quantity must be a positive integer");
  }
  if (quantity > stock) {
    throw new RangeError(`Quantity cannot exceed stock of ${stock}`);
  }
}

function addToCart(cart, products, productId, quantity = 1) {
  const product = requireProduct(products, productId);
  validateQuantity(quantity, product.stock);
  const existing = cart.find((line) => line.productId === productId);
  const nextQuantity = (existing?.quantity ?? 0) + quantity;
  validateQuantity(nextQuantity, product.stock);

  if (existing) {
    return cart.map((line) =>
      line.productId === productId
        ? { ...line, quantity: nextQuantity }
        : line,
    );
  }

  return [...cart, { productId, quantity }];
}

function setCartQuantity(cart, products, productId, quantity) {
  const product = requireProduct(products, productId);
  const exists = cart.some((line) => line.productId === productId);
  if (!exists) {
    throw new Error(`Product ${productId} is not in the cart`);
  }
  validateQuantity(quantity, product.stock);

  return cart.map((line) =>
    line.productId === productId ? { ...line, quantity } : line,
  );
}

function removeFromCart(cart, productId) {
  return cart.filter((line) => line.productId !== productId);
}

Quantity zero is not silently interpreted as deletion; removal has its own explicit operation. This keeps each function contract clear.

Derive detailed lines and money values:

js
function getDetailedLines(cart, products) {
  return cart.map((line) => {
    const product = requireProduct(products, line.productId);
    return {
      productId: line.productId,
      name: product.name,
      unitPrice: product.price,
      quantity: line.quantity,
      lineTotal: product.price * line.quantity,
    };
  });
}

function getSubtotal(lines) {
  return lines.reduce((total, line) => total + line.lineTotal, 0);
}

function getDiscount(subtotal, coupon) {
  if (coupon === undefined || coupon === "") {
    return 0;
  }
  if (coupon === "SAVE10") {
    return subtotal * 0.1;
  }
  if (coupon === "SAVE20OVER75") {
    return subtotal >= 75 ? subtotal * 0.2 : 0;
  }
  throw new Error(`Coupon ${coupon} is invalid`);
}

function getCheckoutSummary(cart, products, coupon) {
  if (cart.length === 0) {
    throw new Error("Cannot checkout an empty cart");
  }

  const lines = getDetailedLines(cart, products);
  const subtotal = getSubtotal(lines);
  const discount = getDiscount(subtotal, coupon);

  return {
    lines,
    itemCount: cart.reduce((count, line) => count + line.quantity, 0),
    subtotal,
    discount,
    total: subtotal - discount,
  };
}

Run the flow:

js
let cart = [];
cart = addToCart(cart, products, "p1", 3);
cart = addToCart(cart, products, "p3", 2);
cart = addToCart(cart, products, "p4", 1);

const summary = getCheckoutSummary(cart, products, "SAVE10");
console.log(summary.lines);
console.log(`Items: ${summary.itemCount}`);
console.log(`Subtotal: $${summary.subtotal.toFixed(2)}`);
console.log(`Discount: $${summary.discount.toFixed(2)}`);
console.log(`Total: $${summary.total.toFixed(2)}`);

Output:

text
[
  { productId: "p1", name: "Notebook", unitPrice: 4, quantity: 3, lineTotal: 12 },
  { productId: "p3", name: "Water Bottle", unitPrice: 16, quantity: 2, lineTotal: 32 },
  { productId: "p4", name: "Backpack", unitPrice: 45, quantity: 1, lineTotal: 45 }
]
Items: 6
Subtotal: $89.00
Discount: $8.90
Total: $80.10

Money caveat: binary floating-point can produce tiny representation differences. Displaying with toFixed(2) formats output but returns a string. Production payment systems often calculate integer minor units (for example cents/paise) according to explicit rounding rules. Today, keep arithmetic readable and never repeatedly round intermediate values without a business requirement.

Intermediate example: validate an existing cart

A cart may come from storage or an API, so validate all lines without throwing on the first one:

js
function getCartIssues(cart, products) {
  return cart
    .map((line) => {
      const product = products.find((item) => item.id === line.productId);
      if (!product) {
        return `Product ${line.productId} no longer exists`;
      }
      if (!Number.isInteger(line.quantity) || line.quantity < 1) {
        return `${product.name} has an invalid quantity`;
      }
      if (line.quantity > product.stock) {
        return `${product.name} has only ${product.stock} available`;
      }
      return null;
    })
    .filter((issue) => issue !== null);
}

const savedCart = [
  { productId: "p3", quantity: 8 },
  { productId: "p99", quantity: 1 },
];
console.log(getCartIssues(savedCart, products));

Output:

text
["Water Bottle has only 7 available", "Product p99 no longer exists"]

This map-then-filter pipeline is clearer than forcing issue collection through a copied array accumulator on every reduction. For very large lists, a straightforward for...of loop that pushes into a local result may be both clear and efficient.

Optional advanced extension

Represent money in integer minor units:

js
const productsInCents = [
  { id: "p1", name: "Notebook", priceCents: 425, stock: 12 },
];

function percentageDiscountCents(subtotalCents, percent) {
  return Math.round(subtotalCents * percent / 100);
}

The chosen rounding rule is part of the business domain. Do not convert an existing course dataset halfway through an operation; use one representation consistently.

Common mistakes and debugging

  • Duplicating price in cart state: it becomes stale when catalog prices change. Derive details at checkout, unless price snapshots are an explicit order requirement.
  • Validating added quantity, not resulting quantity: combine with existing line first, then compare to stock.
  • Treating zero as removal accidentally: give removal a separate operation.
  • Using map() to remove: map preserves length; use filter().
  • One giant reducer: split lookup, validation, detail mapping, and numeric aggregation.
  • Applying percentage as subtotal - 10: 10% means subtotal * 0.10 discount.
  • Formatting too early: toFixed() returns a string; keep numbers through calculations and format last.
  • Assuming every() rejects an empty cart: enforce cart.length > 0 separately.

Best practices

  • Keep cart state minimal and product data authoritative.
  • Validate the resulting quantity before returning state.
  • Make business rules named functions with explicit parameters.
  • Calculate each amount once: subtotal, discount, then final total.
  • Use reduce() for transparent sums, not all transformations.
  • Test empty, missing-product, boundary-stock, repeated-add, invalid-coupon, and original-state cases.

Exercises

Core

Using the sample products, create two cart lines and calculate item count and subtotal.

js
const exerciseCart = [
  { productId: "p1", quantity: 2 },
  { productId: "p4", quantity: 1 },
];
const lines = getDetailedLines(exerciseCart, products);
const count = exerciseCart.reduce((sum, line) => sum + line.quantity, 0);
console.log(count);
console.log(getSubtotal(lines));

Output:

text
3
53

Practice

Implement setCartQuantity behavior where valid quantity updates one copied line and leaves the original unchanged. Demonstrate it with p1 quantity 4.

js
const before = [{ productId: "p1", quantity: 2 }];
const after = setCartQuantity(before, products, "p1", 4);

console.log(after);
console.log(before);
console.log(after === before);
console.log(after[0] === before[0]);

Output:

text
[{ productId: "p1", quantity: 4 }]
[{ productId: "p1", quantity: 2 }]
false
false

Professional Extension

Add coupon BULK5: discount 5% only when total item count is at least 5. Keep getDiscount focused by passing item count as an options field.

js
function getDiscount(subtotal, coupon, { itemCount = 0 } = {}) {
  if (!coupon) return 0;
  if (coupon === "SAVE10") return subtotal * 0.1;
  if (coupon === "SAVE20OVER75") {
    return subtotal >= 75 ? subtotal * 0.2 : 0;
  }
  if (coupon === "BULK5") {
    return itemCount >= 5 ? subtotal * 0.05 : 0;
  }
  throw new Error(`Coupon ${coupon} is invalid`);
}

const itemCount = cart.reduce((count, line) => count + line.quantity, 0);
const lines = getDetailedLines(cart, products);
const subtotal = getSubtotal(lines);
const discount = getDiscount(subtotal, "BULK5", { itemCount });

console.log(itemCount, subtotal, discount, subtotal - discount);

Output: 6 89 4.45 84.55

Recap

Explain the cart/catalog split, resulting-quantity validation, and calculation order. Which operations use find, map, filter, and reduce? Why format money only at the edge? Name two tests that protect against incorrect checkout state.

Official references