Module: JavaScript
JavaScript·065·5 MIN READ

065: Core JavaScript Practice: In-Memory Product Management

TOPICS COVERED: 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

  1. Match each operation to an array method: transform each, keep matches, find one, aggregate.
  2. What containers must be copied when updating one object in an array?
  3. What should a domain module usually return instead of printing?
  4. 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

This product manager is a state transition pipeline:

text
current products + requested operation
        -> validate
        -> find/transform
        -> next products or clear failure

The array variable may be reassigned by the entry module, but 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.

Before coding each operation, state its 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.

Worked beginner example: complete product service

Start with realistic state:

js
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" },
];

Define reusable validation. Product IDs and names must be non-empty strings; price must be a positive finite number; stock must be a non-negative integer.

js
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];
}

Raw boundary values are type-checked and all input issues are collected before any call to trim(). The normalized domain record is then validated, and the duplicate-ID invariant is checked against current state. No state change occurs before every check passes.

For controlled updates, allow only named fields rather than spreading arbitrary input:

js
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. 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 as an entry script:

js
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:

text
{ 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 proves the original records were not changed; Backpack still has stock 3 in initialProducts. The final failure lists every invalid patch value, and the catalog remains unchanged.

Intermediate example: split into modules

Organize without changing the algorithm:

text
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:

js
// 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. The service module is reusable in a future UI because it has no DOM or console dependency.

Optional advanced extension

Return operation metadata so the entry layer can show precise feedback:

js
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. Choose 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.
  • Duplicate IDs: check with some()/find() before adding.
  • Blind patch spread: { ...current, ...changes } may allow ID replacement or unwanted fields. Whitelist supported fields.
  • Using fallback operators for patches: || discards valid 0, while ?? treats invalid null as omitted. Validate supplied values and use explicit undefined checks.
  • Update via find() then assignment: this mutates the shared record. Use map() and a copied product.
  • Delete via splice(): it mutates. Use filter() for an immutable-style deletion.
  • Returning undefined then reading immediately: check find results or define a required lookup.
  • Putting state in every module: establish one owner, usually the entry/UI layer.

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.

js
function getLowStockProducts(products, limit) {
  return products.filter(
    (product) => product.stock > 0 && product.stock <= limit,
  );
}

console.log(getLowStockProducts(initialProducts, 5).map((p) => p.name));
// ["Backpack"]

Practice

Write changeStock(products, productId, difference). Reject missing IDs and a result below zero. Return a new array.

js
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

Professional Extension

Write applyCategoryDiscount(products, category, percent) that validates 0 <= percent <= 100, returns new objects only for matching products, and preserves IDs/stocks.

js
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:

text
[4, 12, 33.75]
[4, 16, 45]
true

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?

Official references