FullStack Course LogoFullStack Course
Module: JavaScript
JavaScript·066·6 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

Before looking at the implementation, retrieve a few ideas that will guide the design:

  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

The useful first distinction is between facts in the catalog and choices made by the customer. Keep them in separate structures:

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

When you need to calculate or display a cart, join each cart line to its product by ID. The catalog remains the source for the current price and name, while the cart owns the selected quantity. The trade-off is that a product can disappear from the catalog, so the calculation path must handle that case explicitly.

The implementation is easier to reason about when each stage has one job:

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

Avoid turning all of this into one large reduce() that validates data, performs lookups, applies discounts, and formats output. reduce() is a good fit for a clear numeric sum. For other intent, map(), find(), some(), every(), and ordinary named functions communicate the operation more directly.

Worked beginner example: cart operations

Start with a small catalog. A cart stores only product IDs and quantities; it does not copy the catalog fields into its own state.

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);
}

There are two quantity checks in addToCart. The first checks the requested increment. The second checks the quantity that will actually be stored after an existing line is found. Checking only the increment would allow repeated additions to exceed available stock.

setCartQuantity replaces the selected quantity, while addToCart increases it. Both return new arrays and, for an updated line, a new line object. removeFromCart uses filter() because the desired result has fewer items. Quantity zero is not interpreted as deletion; removal has its own explicit operation. Keeping those contracts separate makes callers and tests easier to understand.

Next, derive the display and money values from the minimal cart state:

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,
  };
}

The calculation order is deliberate: resolve the current product details, calculate each line, sum those lines, apply the coupon rule, and only then calculate the final total. itemCount is a separate derived value because it counts units, not cart lines.

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

There is a money detail worth keeping in view. Binary floating-point can produce tiny representation differences. toFixed(2) formats the display, but its return value is a string. Production payment systems often use integer minor units, such as cents or paise, and apply explicit rounding rules. For this lesson, keep the arithmetic readable; do not repeatedly round intermediate values unless the business rules require it.

Intermediate example: validate an existing cart

A cart restored from storage or received through an API may not satisfy the assumptions enforced by the update functions. Instead of throwing at the first bad line, inspect every line and return all the issues so the caller can report or repair them together:

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"]

The map()-then-filter() pipeline makes the two stages visible: map each line to either an issue or null, then keep only the issues. That is clearer here 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 can be both clear and efficient.

Optional advanced extension

If the application represents money as integer minor units, the same principle can be applied to percentage discounts:

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

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

The rounding rule is part of the business domain, not merely a formatting choice. Do not convert an existing course dataset halfway through an operation. Choose one representation and use it consistently.

Common mistakes and debugging

  • Duplicating price in cart state: the copied value 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 the requested amount with the existing line first, then compare the result with stock.
  • Treating zero as removal accidentally: give removal a separate operation.
  • Using map() to remove: map() preserves the array length; use filter() when items need to be excluded.
  • One giant reducer: split lookup, validation, detail mapping, and numeric aggregation so each stage can be inspected independently.
  • Applying percentage as subtotal - 10: 10% means a discount of subtotal * 0.10, not a flat subtraction of 10.
  • Formatting too early: toFixed() returns a string; keep numbers through the calculations and format at the edge.
  • Assuming every() rejects an empty cart: enforce cart.length > 0 separately, because an empty array passes every().

When checkout produces an unexpected amount, inspect the derived lines first, then the subtotal, then the discount inputs and rule. That sequence follows the calculation pipeline and usually identifies whether the problem is a catalog lookup, quantity, arithmetic, or coupon issue.

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 a 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 should money be formatted only at the edge? Name two tests that protect against incorrect checkout state.

Official references

Reader page: /javascript/lesson/066/core-javascript-practice-shopping-cart-logic