065: Core JavaScript Practice: In-Memory Product Management
Learning outcomes
By the end of this lesson, you can:
- explain data flow through product create, read, update, and delete operations;
- combine functions, arrays, objects, and modules without global mutation;
- validate product input before changing application state;
- implement add, find, filter, update, and delete operations in memory;
- verify results and unchanged inputs with observable checks.
Retrieval warm-up
Before building the service, retrieve a few array and immutability ideas you will use repeatedly:
- Match each operation to an array method: transform each, keep matches, find one, aggregate.
- What containers must be copied when updating one object in an array?
- What should a domain module usually return instead of printing?
- When can a
find()result be safely used without optional chaining?
Answers: map, filter, find, reduce; new array and changed object (plus any changed nested path); values; after checking or using a required lookup that throws.
Vocabulary
- CRUD: Create, Read, Update, Delete — the four fundamental data operations. — Source: MDN: Glossary — CRUD
- Catalog state: the current in-memory array of product records (course term).
- Data flow: the route from input through validation/transformation to output (course term).
- Invariant: a rule that valid state must always satisfy (course term).
- Patch: a controlled set of fields to update (course term).
- Pure function: same inputs produce the same output without changing inputs (course term).
- In-memory: data exists only while the program runs; it is not persisted (course term).
- Boundary: a point where untrusted or loosely shaped input becomes domain data (course term).
- CRUD (official): "CRUD stands for Create, Read, Update, Delete — four basic data operations." — Source: MDN Glossary: CRUD
- Data flow (official): "Data flow is the path data takes through a program, from input to storage to rendering." — Source: MDN: MVC — Data flow
Beginner mental model
The service is easiest to reason about as a state-transition pipeline. A caller supplies the current catalog and the operation it wants; the operation either produces a valid next value or fails without changing the current one.
current products + requested operation
-> validate
-> find/transform
-> next products or clear failure
The array variable may be reassigned by the entry module, but the operation functions do not mutate the input array or product objects. Each successful write returns the next catalog. Reads return a product, list, or summary.
That distinction is useful in debugging: the service computes values, while the caller decides where the current value lives and what to do with it. Before coding an operation, write down its input and output shape:
- Add: products + product input -> new products array.
- Find: products + ID -> one product or
undefined. - Filter: products + query -> new array of zero or more references.
- Update: products + ID + changes -> new products array.
- Delete: products + ID -> new products array.
Filtering returns a new outer array but does not clone matching products. That is acceptable for a read result if callers treat records as read-only. Update copies the one changed product. The useful rule is to copy every container along the path you change, not to clone everything indiscriminately.
Worked beginner example: complete product service
Start with a small but believable catalog. The records are plain objects, and the array is our in-memory state for this lesson:
const initialProducts = [
{ id: "p1", name: "Notebook", price: 4, stock: 12, category: "study" },
{ id: "p3", name: "Water Bottle", price: 16, stock: 7, category: "travel" },
{ id: "p4", name: "Backpack", price: 45, stock: 3, category: "travel" },
];
Validation belongs at the boundary, before raw values are normalized or allowed to affect state. Product IDs and names must be non-empty strings; price must be a positive finite number; stock must be a non-negative integer.
function validateProduct(product) {
const issues = [];
if (typeof product.id !== "string" || product.id.trim() === "") {
issues.push("ID is required");
}
if (typeof product.name !== "string" || product.name.trim() === "") {
issues.push("Name is required");
}
if (
typeof product.price !== "number" ||
!Number.isFinite(product.price) ||
product.price <= 0
) {
issues.push("Price must be a positive finite number");
}
if (!Number.isInteger(product.stock) || product.stock < 0) {
issues.push("Stock must be a non-negative integer");
}
return issues;
}
function validateProductInput(input, partial = false) {
const issues = [];
if (typeof input !== "object" || input === null || Array.isArray(input)) {
issues.push("Product input must be an object");
return issues;
}
if (!partial || input.id !== undefined) {
if (typeof input.id !== "string" || input.id.trim() === "") {
issues.push("ID must be a non-empty string");
}
}
if (!partial || input.name !== undefined) {
if (typeof input.name !== "string" || input.name.trim() === "") {
issues.push("Name must be a non-empty string");
}
}
if (!partial || input.price !== undefined) {
if (
typeof input.price !== "number" ||
!Number.isFinite(input.price) ||
input.price <= 0
) {
issues.push("Price must be a positive finite number");
}
}
if (!partial || input.stock !== undefined) {
if (!Number.isInteger(input.stock) || input.stock < 0) {
issues.push("Stock must be a non-negative integer");
}
}
if (input.category !== undefined && typeof input.category !== "string") {
issues.push("Category must be a string when provided");
}
return issues;
}
function findProduct(products, productId) {
return products.find((product) => product.id === productId);
}
function addProduct(products, input) {
const inputIssues = validateProductInput(input);
if (inputIssues.length > 0) {
throw new Error(`Invalid product input: ${inputIssues.join("; ")}`);
}
const product = {
id: input.id.trim(),
name: input.name.trim(),
price: input.price,
stock: input.stock,
category:
input.category === undefined || input.category.trim() === ""
? "uncategorized"
: input.category.trim(),
};
const issues = validateProduct(product);
if (issues.length > 0) {
throw new Error(`Invalid product: ${issues.join("; ")}`);
}
if (findProduct(products, product.id)) {
throw new Error(`Product ID ${product.id} already exists`);
}
return [...products, product];
}
There are several deliberate stages here. Raw boundary values are type-checked, and all input issues are collected before any call to trim(). The normalized domain record is validated again, then the duplicate-ID invariant is checked against the current state. No state change occurs before every check passes.
For controlled updates, allow only named fields rather than spreading arbitrary input. An unrestricted { ...current, ...changes } would also let a caller replace id or add fields the service does not support.
function updateProduct(products, productId, changes) {
const current = findProduct(products, productId);
if (!current) {
throw new Error(`Product ${productId} was not found`);
}
const inputIssues = validateProductInput(changes, true);
if (inputIssues.length > 0) {
throw new Error(`Invalid product update: ${inputIssues.join("; ")}`);
}
const draft = {
...current,
name: changes.name === undefined ? current.name : changes.name.trim(),
price: changes.price === undefined ? current.price : changes.price,
stock: changes.stock === undefined ? current.stock : changes.stock,
category:
changes.category === undefined
? current.category
: changes.category.trim(),
};
const issues = validateProduct(draft);
if (issues.length > 0) {
throw new Error(`Invalid product update: ${issues.join("; ")}`);
}
return products.map((product) =>
product.id === productId ? draft : product,
);
}
function deleteProduct(products, productId) {
const exists = products.some((product) => product.id === productId);
if (!exists) {
throw new Error(`Product ${productId} was not found`);
}
return products.filter((product) => product.id !== productId);
}
function filterProducts(products, { query = "", category, inStockOnly = false } = {}) {
const term = query.trim().toLowerCase();
return products.filter((product) => {
const matchesText = product.name.toLowerCase().includes(term);
const matchesCategory = category === undefined || product.category === category;
const matchesStock = !inStockOnly || product.stock > 0;
return matchesText && matchesCategory && matchesStock;
});
}
Every patch field uses an explicit === undefined check. Omission means unchanged, while null is present and therefore fails raw type validation. This also preserves a requested stock of 0; using || here would incorrectly replace that valid value. Validation collects every bad supplied field before normalization, so { name: null, price: Infinity, stock: -1 } reports all three issues without calling trim() or changing state.
Run the service from an entry script. The entry layer owns reassignment, logging, and error handling; the service functions simply return values or throw for invalid operations.
let products = initialProducts;
products = addProduct(products, {
id: "p5",
name: " USB Cable ",
price: 9,
stock: 20,
category: "tech",
});
products = updateProduct(products, "p4", { price: 40, stock: 0 });
console.log(findProduct(products, "p5"));
console.log(filterProducts(products, { category: "travel" }).map((p) => p.name));
products = deleteProduct(products, "p1");
console.log(products.map((product) => product.id));
console.log(initialProducts.map((product) => `${product.id}:${product.stock}`));
try {
updateProduct(products, "p4", { name: null, price: Infinity, stock: -1 });
} catch (error) {
console.log(error.message);
}
Output:
{ id: "p5", name: "USB Cable", price: 9, stock: 20, category: "tech" }
["Water Bottle", "Backpack"]
["p3", "p4", "p5"]
["p1:12", "p3:7", "p4:3"]
Invalid product update: Name must be a non-empty string; Price must be a positive finite number; Stock must be a non-negative integer
The fourth line is an observable immutability check: the original records were not changed, so Backpack still has stock 3 in initialProducts. The final failure lists every invalid patch value, and because the operation failed before returning a new catalog, the catalog remains unchanged.
Intermediate example: split into modules
Once the single-file algorithm is clear, split responsibilities without changing the algorithm:
product-validation.js -> validateProduct
product-service.js -> addProduct, findProduct, filterProducts,
updateProduct, deleteProduct
products.js -> initialProducts
main.js -> owns current `products`, handles errors and output
Example boundaries:
// product-service.js
import { validateProduct } from "./product-validation.js";
export { addProduct, findProduct, filterProducts, updateProduct, deleteProduct };
// main.js
import { initialProducts } from "./products.js";
import { addProduct, updateProduct } from "./product-service.js";
let products = initialProducts;
try {
products = addProduct(products, newProductInput);
products = updateProduct(products, "p4", { stock: 0 });
} catch (error) {
console.error(error instanceof Error ? error.message : "Unknown failure");
}
Only main.js owns reassignment and presentation. That single ownership rule prevents several modules from silently maintaining competing versions of the catalog. The service module is reusable in a future UI because it has no DOM or console dependency.
Optional advanced extension
Exceptions are useful for invalid operations, but some applications prefer an explicit result object for expected failures. Return operation metadata when the entry layer needs precise feedback:
function deleteProductResult(products, productId) {
const product = findProduct(products, productId);
if (!product) {
return { ok: false, products, message: "Product not found" };
}
return {
ok: true,
products: products.filter((item) => item.id !== productId),
deleted: product,
};
}
This result-object style is useful for expected failures, especially when a UI needs to render a message without using exceptions as ordinary control flow. Choose one convention consistently: do not unpredictably mix undefined, throws, and result objects for the same service family.
Common mistakes and debugging
- Mutating state before validation: create and validate a draft first. If the operation later fails, inspect whether the original array or record changed.
- Duplicate IDs: check with
some()/find()before adding. A duplicate-ID error is an invariant failure, not a reason to overwrite an existing record. - Blind patch spread:
{ ...current, ...changes }may allow ID replacement or unwanted fields. Whitelist supported fields. - Using fallback operators for patches:
||discards valid0, while??treats invalidnullas omitted. Validate supplied values and use explicitundefinedchecks. - Update via
find()then assignment: this mutates the shared record. Usemap()and a copied product. - Delete via
splice(): it mutates. Usefilter()for an immutable-style deletion. - Returning
undefinedthen reading immediately: check find results or define a required lookup. An absent optional product is normal; dereferencing it is the bug. - Putting state in every module: establish one owner, usually the entry/UI layer. When results disagree, first inspect where reassignment occurs.
Best practices
- Explain operation input, transformation, and output before implementation.
- Keep stable IDs immutable and unique.
- Validate raw boundary types before normalization, then validate the normalized draft.
- Keep write operations atomic and immutable-style.
- Use clear operations rather than one universal "manage products" function.
- Test successful, boundary, missing-ID, duplicate-ID, and unchanged-input cases.
Exercises
Core
Write getLowStockProducts(products, limit) returning products with stock from 1 through limit.
function getLowStockProducts(products, limit) {
return products.filter(
(product) => product.stock > 0 && product.stock <= limit,
);
}
console.log(getLowStockProducts(initialProducts, 5).map((p) => p.name));
// ["Backpack"]
The strict lower bound excludes out-of-stock products, and the upper bound includes products whose stock is exactly limit. filter() returns a new outer array while preserving the matching product references.
Practice
Write changeStock(products, productId, difference). Reject missing IDs and a result below zero. Return a new array.
function changeStock(products, productId, difference) {
const product = products.find((item) => item.id === productId);
if (!product) {
throw new Error(`Product ${productId} was not found`);
}
const nextStock = product.stock + difference;
if (!Number.isInteger(nextStock) || nextStock < 0) {
throw new RangeError("Resulting stock must be a non-negative integer");
}
return products.map((item) =>
item.id === productId ? { ...item, stock: nextStock } : item,
);
}
const next = changeStock(initialProducts, "p3", -2);
console.log(next[1].stock); // 5
console.log(initialProducts[1].stock); // 7
The lookup happens before the calculation so a missing ID produces a clear error. The returned array contains a copied object only for the changed product; the final log verifies that the original record was not mutated.
Professional Extension
Write applyCategoryDiscount(products, category, percent) that validates 0 <= percent <= 100, returns new objects only for matching products, and preserves IDs/stocks.
function applyCategoryDiscount(products, category, percent) {
if (
typeof percent !== "number" ||
!Number.isFinite(percent) ||
percent < 0 ||
percent > 100
) {
throw new RangeError("Percent must be between 0 and 100");
}
return products.map((product) =>
product.category === category
? { ...product, price: product.price * (1 - percent / 100) }
: product,
);
}
const sale = applyCategoryDiscount(initialProducts, "travel", 25);
console.log(sale.map((product) => product.price));
console.log(initialProducts.map((product) => product.price));
console.log(sale[0] === initialProducts[0]);
Output:
[4, 12, 33.75]
[4, 16, 45]
true
The matching travel products receive new objects and discounted prices. The notebook is not in the target category, so its reference is reused; true is the expected result for that unchanged item. IDs and stocks are carried through by the object spread and are not part of the discount calculation.
Recap
Trace one CRUD operation as data flow. Why are IDs protected? Why is filter() suitable for deletion and map() for update? Where does current state live in the modular design? What checks prove the original catalog was not modified?
