066: 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
- Why should a cart line store a product ID rather than duplicate every product field?
- Which method answers whether any cart item is invalid?
- Which method naturally sums line totals?
- 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:
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:
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
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:
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:
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:
[ { 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:
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:
["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:
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; usefilter(). - One giant reducer: split lookup, validation, detail mapping, and numeric aggregation.
- Applying percentage as
subtotal - 10: 10% meanssubtotal * 0.10discount. - Formatting too early:
toFixed()returns a string; keep numbers through calculations and format last. - Assuming
every()rejects an empty cart: enforcecart.length > 0separately.
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.
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:
3 53
Practice
Implement setCartQuantity behavior where valid quantity updates one copied line and leaves the original unchanged. Demonstrate it with p1 quantity 4.
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:
[{ 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.
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.
