Skip to content
aviral gupta

// I2.5 · ~45 min · Intermediate

Build: an inventory model

You build an inventory page in which two classes hold the data and check every change, and the table is drawn from them after each add or remove.

Lesson 5 of 5 in I2 Objects, prototypes and classes

End of the module

You will be able to

  • Model the data with Item and Inventory: private fields, getters for computed values, and a static fromJSON factory
  • Check data in constructors and methods by throwing, and show the error message on the page
  • Render the table and summary from the model after every change
  1. Warm-up · Activity 1 of 7

    Warm-up from lesson I2.4. What does this print?

    class Item {
      #price = 2;
      #quantity = 3;
      get value() {
        return this.#price * this.#quantity;
      }
    }
    const item = new Item();
    console.log(item.value, Object.keys(item).length);
  2. Predict · Activity 2 of 7

    Predict before you read on. Three prices go into the constructor. What does this print?

    class Item {
      constructor(name, price) {
        if (!(price >= 0)) throw new RangeError(`invalid price: ${price}`);
        this.name = name;
        this.price = price;
      }
    }
    const made = [];
    for (const price of [2, -1, Number("abc")]) {
      try {
        made.push(new Item("pen", price).price);
      } catch (error) {
        made.push(error.message);
      }
    }
    console.log(made.join(" | "));
  3. Practice · Activity 3 of 7

    Fill in the keyword that refuses an item that was already added, so the caller’s catch sees the error.

    class Inventory {
      #items = [];
      add(item) {
        if (this.#items.includes(item)) ____ new Error("already added");
        this.#items.push(item);
      }
      get count() {
        return this.#items.length;
      }
    }
    if (this.#items.includes(item))new Error("already added");
  4. Practice · Activity 4 of 7

    The Add button is clicked. Put the steps of the page in order.

    1. 1.The message shows “Added …” or the error’s message
    2. 2.render() rebuilds the rows and the summary from the inventory
    3. 3.new Item(…) is built from the inputs and checks them
    4. 4.The submit listener calls event.preventDefault()
    5. 5.inventory.add(item) checks for a duplicate and stores it
  5. Practice · Activity 5 of 7

    A static factory reads JSON. What does this print?

    class Item {
      constructor(name, price) {
        this.name = name;
        this.price = price;
      }
    }
    class Inventory {
      #items = [];
      static fromJSON(text) {
        const inventory = new Inventory();
        for (const {name, price} of JSON.parse(text)) inventory.add(new Item(name, price));
        return inventory;
      }
      add(item) {
        this.#items.push(item);
      }
      get total() {
        return this.#items.reduce((sum, item) => sum + item.price, 0);
      }
    }
    const inv = Inventory.fromJSON('[{"name": "pen", "price": 2}, {"name": "cup", "price": 5}]');
    console.log(inv.total, inv instanceof Inventory);
  6. Brain teaser · Activity 6 of 7

    Brain teaser. The getter returns the private array itself. What does this print?

    class Inventory {
      #items = ["pen"];
      get items() {
        return this.#items;
      }
      get count() {
        return this.#items.length;
      }
    }
    const inv = new Inventory();
    inv.items.push("hacked");
    console.log(inv.count);
  7. Apply · Activity 7 of 7

    Mini-task: extend your inventory. Add a static field LOW = 5 and a getter lowStock to Inventory that returns the items with a quantity below Inventory.LOW. Add a paragraph under the summary and fill it in render(): "Low stock: Stapler" or "Low stock: none". Try it through a local web server.

    Check your work against this list

Build it yourself

Read the worked example, then write the exercises. Your code runs in your browser or on your computer and is never uploaded.

Worked example

The inventory, start to finish

The page has a form, a message line, a table with an empty body and a summary. main.js holds the two classes, builds the inventory from JSON with Inventory.fromJSON, and renders it. The submit and click listeners change the model and render again. At the end the script uses its own page, the way the exercise tests do: it adds one good item and three bad ones, then removes the stapler.

main.js

class Item {
  #name;
  #price;
  #quantity;

  constructor(name, price, quantity) {
    if (typeof name !== 'string' || name.trim() === '') throw new TypeError('name must not be empty');
    if (!(price >= 0)) throw new RangeError(`invalid price: ${price}`);
    if (!Number.isInteger(quantity) || quantity < 0) throw new RangeError(`invalid quantity: ${quantity}`);
    this.#name = name.trim();
    this.#price = price;
    this.#quantity = quantity;
  }

  get name() {
    return this.#name;
  }

  get price() {
    return this.#price;
  }

  get quantity() {
    return this.#quantity;
  }

  get value() {
    return this.#price * this.#quantity;
  }
}

class Inventory {
  #items = [];

  static fromJSON(text) {
    const inventory = new Inventory();
    for (const { name, price, quantity } of JSON.parse(text)) {
      inventory.add(new Item(name, price, quantity));
    }
    return inventory;
  }

  add(item) {
    if (!(item instanceof Item)) throw new TypeError('only an Item can be added');
    if (this.#items.some((other) => other.name === item.name)) {
      throw new Error(`${item.name} is already in the inventory`);
    }
    this.#items.push(item);
  }

  remove(name) {
    const index = this.#items.findIndex((item) => item.name === name);
    if (index === -1) throw new Error(`${name} is not in the inventory`);
    this.#items.splice(index, 1);
  }

  get items() {
    return [...this.#items];
  }

  get count() {
    return this.#items.length;
  }

  get totalValue() {
    return this.#items.reduce((sum, item) => sum + item.value, 0);
  }
}

const STOCK = '[{"name": "Pencil", "price": 0.5, "quantity": 40}, {"name": "Notebook", "price": 2.25, "quantity": 12}, {"name": "Stapler", "price": 7.9, "quantity": 3}]';
const inventory = Inventory.fromJSON(STOCK);

const form = document.querySelector('#add-form');
const nameInput = document.querySelector('#item-name');
const priceInput = document.querySelector('#item-price');
const quantityInput = document.querySelector('#item-quantity');
const rows = document.querySelector('#rows');
const message = document.querySelector('#message');
const summary = document.querySelector('#summary');

const euro = (amount) => `${amount.toFixed(2)} EUR`;

function cell(content) {
  const td = document.createElement('td');
  td.append(content);
  return td;
}

// The table is always rebuilt from the model.
function render() {
  rows.replaceChildren(
    ...inventory.items.map((item) => {
      const remove = document.createElement('button');
      remove.type = 'button';
      remove.textContent = 'Remove';
      remove.dataset.name = item.name;
      const row = document.createElement('tr');
      row.append(cell(item.name), cell(euro(item.price)), cell(String(item.quantity)), cell(euro(item.value)), cell(remove));
      return row;
    })
  );
  summary.textContent = `${inventory.count} items, total value ${euro(inventory.totalValue)}`;
}

form.addEventListener('submit', (event) => {
  event.preventDefault();
  try {
    const item = new Item(nameInput.value, Number(priceInput.value), Number(quantityInput.value));
    inventory.add(item);
    message.textContent = `Added ${item.name}.`;
    form.reset();
  } catch (error) {
    message.textContent = error.message;
  }
  render();
});

rows.addEventListener('click', (event) => {
  const button = event.target.closest('button');
  if (!button) return;
  try {
    inventory.remove(button.dataset.name);
    message.textContent = `Removed ${button.dataset.name}.`;
  } catch (error) {
    message.textContent = error.message;
  }
  render();
});

render();

// The script tries its own page, the way the exercise tests do.
function submit(name, price, quantity) {
  nameInput.value = name;
  priceInput.value = price;
  quantityInput.value = quantity;
  form.querySelector('button').click();
  console.log(message.textContent);
}
console.log(summary.textContent);
submit('Ruler', '1.2', '10');
submit('Pencil', '0.5', '5');
submit('Eraser', '-1', '5');
submit('Glue', '1.5', '2.5');
rows.querySelector('[data-name="Stapler"]').click();
console.log(message.textContent);
console.log([...rows.querySelectorAll('tr')].map((row) => row.firstChild.textContent).join(', '));

index.html

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Inventory</title>
</head>
<body>
  <h1>Inventory</h1>
  <form id="add-form" novalidate>
    <label>Name <input id="item-name"></label>
    <label>Price <input id="item-price" type="number" step="0.01"></label>
    <label>Quantity <input id="item-quantity" type="number"></label>
    <button type="submit">Add</button>
  </form>
  <p id="message"></p>
  <table>
    <thead>
      <tr><th>Item</th><th>Price</th><th>Quantity</th><th>Value</th><th></th></tr>
    </thead>
    <tbody id="rows"></tbody>
  </table>
  <p id="summary"></p>
  <script type="module" src="main.js"></script>
</body>
</html>

To run the page on your computer, open index.html through a local web server, not as a file: browsers do not run module scripts from file:// addresses. For the checks, install jsdom first (the commands below).

Output

3 items, total value 70.70 EUR
Added Ruler.
Pencil is already in the inventory
invalid price: -1
invalid quantity: 2.5
Removed Stapler.
Pencil, Notebook, Ruler
<!-- #summary -->
<p id="summary">3 items, total value 59.00 EUR</p>
  • The three refused items never reached the table: each constructor or add threw, and the message shows why.
  • The total value is computed from the items on every render, never stored.
  • After Remove, the stapler is gone from the model, so render() drew only three rows.
  • toFixed(2) turned 7.9 × 3, which is 23.700000000000003 in floating point, into 23.70.
Change it and run it

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads the JavaScript runner (up to 0.1 MB) and keeps it cached. Your code runs in your browser’s own engine and stays on your device.

Your page

Your page runs in an isolated frame. It has no access to this site, and its localStorage is kept in memory for this run only: the next run starts empty.

Run the program to see its page here.

Exercises

Exercise 1 of 3

Items that refuse bad data

Finish the constructor of Item. It must throw a TypeError with the message name must not be empty for a name that is empty or only spaces, a RangeError invalid price: -1 for a price that is not >= 0, and a RangeError invalid quantity: 2.5 for a quantity that is not a whole number >= 0. The page already shows the message of any error. The starter checks nothing.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads the JavaScript runner (up to 0.1 MB) and keeps it cached. Your code runs in your browser’s own engine and stays on your device.

Your page

Your page runs in an isolated frame. It has no access to this site, and its localStorage is kept in memory for this run only: the next run starts empty.

Run the program to see its page here.

Hints
  1. Hint 1

    Run the tests: the bad items are added. Where should an Item refuse its data, before it stores anything?

  2. Hint 2

    Three if statements at the start of the constructor, each with throw. For the name: typeof name !== 'string' || name.trim() === ''.

  3. Hint 3

    !(price >= 0) also refuses NaN. For the quantity: !Number.isInteger(quantity) || quantity < 0.

Show a solution

One way to solve it. Yours can look different and still pass the checks.

class Item {
  #name;
  #price;
  #quantity;

  constructor(name, price, quantity) {
    if (typeof name !== 'string' || name.trim() === '') throw new TypeError('name must not be empty');
    if (!(price >= 0)) throw new RangeError(`invalid price: ${price}`);
    if (!Number.isInteger(quantity) || quantity < 0) throw new RangeError(`invalid quantity: ${quantity}`);
    this.#name = name.trim();
    this.#price = price;
    this.#quantity = quantity;
  }

  get name() {
    return this.#name;
  }

  get price() {
    return this.#price;
  }

  get quantity() {
    return this.#quantity;
  }

  get value() {
    return this.#price * this.#quantity;
  }
}

class Inventory {
  #items = [];

  static fromJSON(text) {
    const inventory = new Inventory();
    for (const { name, price, quantity } of JSON.parse(text)) {
      inventory.add(new Item(name, price, quantity));
    }
    return inventory;
  }

  add(item) {
    if (!(item instanceof Item)) throw new TypeError('only an Item can be added');
    if (this.#items.some((other) => other.name === item.name)) {
      throw new Error(`${item.name} is already in the inventory`);
    }
    this.#items.push(item);
  }

  remove(name) {
    const index = this.#items.findIndex((item) => item.name === name);
    if (index === -1) throw new Error(`${name} is not in the inventory`);
    this.#items.splice(index, 1);
  }

  get items() {
    return [...this.#items];
  }

  get count() {
    return this.#items.length;
  }

  get totalValue() {
    return this.#items.reduce((sum, item) => sum + item.value, 0);
  }
}

const STOCK = '[{"name": "Pencil", "price": 0.5, "quantity": 40}, {"name": "Notebook", "price": 2.25, "quantity": 12}, {"name": "Stapler", "price": 7.9, "quantity": 3}]';
const inventory = Inventory.fromJSON(STOCK);

const form = document.querySelector('#add-form');
const nameInput = document.querySelector('#item-name');
const priceInput = document.querySelector('#item-price');
const quantityInput = document.querySelector('#item-quantity');
const rows = document.querySelector('#rows');
const message = document.querySelector('#message');
const summary = document.querySelector('#summary');

const euro = (amount) => `${amount.toFixed(2)} EUR`;

function cell(content) {
  const td = document.createElement('td');
  td.append(content);
  return td;
}

// The table is always rebuilt from the model.
function render() {
  rows.replaceChildren(
    ...inventory.items.map((item) => {
      const remove = document.createElement('button');
      remove.type = 'button';
      remove.textContent = 'Remove';
      remove.dataset.name = item.name;
      const row = document.createElement('tr');
      row.append(cell(item.name), cell(euro(item.price)), cell(String(item.quantity)), cell(euro(item.value)), cell(remove));
      return row;
    })
  );
  summary.textContent = `${inventory.count} items, total value ${euro(inventory.totalValue)}`;
}

form.addEventListener('submit', (event) => {
  event.preventDefault();
  try {
    const item = new Item(nameInput.value, Number(priceInput.value), Number(quantityInput.value));
    inventory.add(item);
    message.textContent = `Added ${item.name}.`;
    form.reset();
  } catch (error) {
    message.textContent = error.message;
  }
  render();
});

rows.addEventListener('click', (event) => {
  const button = event.target.closest('button');
  if (!button) return;
  try {
    inventory.remove(button.dataset.name);
    message.textContent = `Removed ${button.dataset.name}.`;
  } catch (error) {
    message.textContent = error.message;
  }
  render();
});

render();
Run it on your computer

Install ECMAScript 2026 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.js

class Item {
  #name;
  #price;
  #quantity;

  constructor(name, price, quantity) {
    // TODO: throw a TypeError for an empty name, and a RangeError for a price
    // that is not >= 0 or a quantity that is not a whole number >= 0.
    this.#name = name.trim();
    this.#price = price;
    this.#quantity = quantity;
  }

  get name() {
    return this.#name;
  }

  get price() {
    return this.#price;
  }

  get quantity() {
    return this.#quantity;
  }

  get value() {
    return this.#price * this.#quantity;
  }
}

class Inventory {
  #items = [];

  static fromJSON(text) {
    const inventory = new Inventory();
    for (const { name, price, quantity } of JSON.parse(text)) {
      inventory.add(new Item(name, price, quantity));
    }
    return inventory;
  }

  add(item) {
    if (!(item instanceof Item)) throw new TypeError('only an Item can be added');
    if (this.#items.some((other) => other.name === item.name)) {
      throw new Error(`${item.name} is already in the inventory`);
    }
    this.#items.push(item);
  }

  remove(name) {
    const index = this.#items.findIndex((item) => item.name === name);
    if (index === -1) throw new Error(`${name} is not in the inventory`);
    this.#items.splice(index, 1);
  }

  get items() {
    return [...this.#items];
  }

  get count() {
    return this.#items.length;
  }

  get totalValue() {
    return this.#items.reduce((sum, item) => sum + item.value, 0);
  }
}

const STOCK = '[{"name": "Pencil", "price": 0.5, "quantity": 40}, {"name": "Notebook", "price": 2.25, "quantity": 12}, {"name": "Stapler", "price": 7.9, "quantity": 3}]';
const inventory = Inventory.fromJSON(STOCK);

const form = document.querySelector('#add-form');
const nameInput = document.querySelector('#item-name');
const priceInput = document.querySelector('#item-price');
const quantityInput = document.querySelector('#item-quantity');
const rows = document.querySelector('#rows');
const message = document.querySelector('#message');
const summary = document.querySelector('#summary');

const euro = (amount) => `${amount.toFixed(2)} EUR`;

function cell(content) {
  const td = document.createElement('td');
  td.append(content);
  return td;
}

// The table is always rebuilt from the model.
function render() {
  rows.replaceChildren(
    ...inventory.items.map((item) => {
      const remove = document.createElement('button');
      remove.type = 'button';
      remove.textContent = 'Remove';
      remove.dataset.name = item.name;
      const row = document.createElement('tr');
      row.append(cell(item.name), cell(euro(item.price)), cell(String(item.quantity)), cell(euro(item.value)), cell(remove));
      return row;
    })
  );
  summary.textContent = `${inventory.count} items, total value ${euro(inventory.totalValue)}`;
}

form.addEventListener('submit', (event) => {
  event.preventDefault();
  try {
    const item = new Item(nameInput.value, Number(priceInput.value), Number(quantityInput.value));
    inventory.add(item);
    message.textContent = `Added ${item.name}.`;
    form.reset();
  } catch (error) {
    message.textContent = error.message;
  }
  render();
});

rows.addEventListener('click', (event) => {
  const button = event.target.closest('button');
  if (!button) return;
  try {
    inventory.remove(button.dataset.name);
    message.textContent = `Removed ${button.dataset.name}.`;
  } catch (error) {
    message.textContent = error.message;
  }
  render();
});

render();

main.test.js

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {loadPage} from './learnrun.js';

const text = (page, selector) => page.document.querySelector(selector).textContent;
const names = (page) => [...page.document.querySelectorAll('#rows tr')].map((row) => row.firstChild.textContent);
function submit(page, name, price, quantity) {
  page.document.querySelector('#item-name').value = name;
  page.document.querySelector('#item-price').value = price;
  page.document.querySelector('#item-quantity').value = quantity;
  page.document.querySelector('#add-form button').click();
}
function removeButton(page, name) {
  const button = page.document.querySelector(`#rows button[data-name="${name}"]`);
  assert.ok(button, `there is no Remove button for ${name}; the rows are ${JSON.stringify(names(page))}`);
  return button;
}

test('A valid item is added as a new row', async () => {
  const page = await loadPage();
  submit(page, 'Ruler', '1.2', '10');
  assert.equal(text(page, '#message'), 'Added Ruler.', 'the message is ' + text(page, '#message'));
  assert.deepEqual(names(page), ['Pencil', 'Notebook', 'Stapler', 'Ruler'], 'the rows are ' + JSON.stringify(names(page)));
});

test('A negative price is refused', async () => {
  const page = await loadPage();
  submit(page, 'Eraser', '-1', '5');
  assert.equal(text(page, '#message'), 'invalid price: -1', 'the message is ' + text(page, '#message'));
  assert.equal(names(page).length, 3, 'the rows are ' + JSON.stringify(names(page)));
});

test('A quantity of 2.5 is refused', async () => {
  const page = await loadPage();
  submit(page, 'Glue', '1.5', '2.5');
  assert.equal(text(page, '#message'), 'invalid quantity: 2.5', 'the message is ' + text(page, '#message'));
});

test('A name of spaces only is refused', async () => {
  const page = await loadPage();
  submit(page, '   ', '1', '1');
  assert.equal(text(page, '#message'), 'name must not be empty', 'the message is ' + text(page, '#message'));
  assert.equal(names(page).length, 3, 'the rows are ' + JSON.stringify(names(page)));
});

index.html

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Inventory</title>
</head>
<body>
  <h1>Inventory</h1>
  <form id="add-form" novalidate>
    <label>Name <input id="item-name"></label>
    <label>Price <input id="item-price" type="number" step="0.01"></label>
    <label>Quantity <input id="item-quantity" type="number"></label>
    <button type="submit">Add</button>
  </form>
  <p id="message"></p>
  <table>
    <thead>
      <tr><th>Item</th><th>Price</th><th>Quantity</th><th>Value</th><th></th></tr>
    </thead>
    <tbody id="rows"></tbody>
  </table>
  <p id="summary"></p>
  <script type="module" src="main.js"></script>
</body>
</html>

package.json

{
  "type": "module"
}

package.json tells Node.js that the .js files are modules; keep it in the folder.

Run the program:

To run the page on your computer, open index.html through a local web server, not as a file: browsers do not run module scripts from file:// addresses. For the checks, install jsdom first (the commands below).

Run the checks (needs learnrun.js in the same folder):

npm install jsdom@28.1.0
node --test
Download learnrun.js

Exercise 2 of 3

Remove an item

Finish remove(name) in Inventory. Find the item with that name in the private #items array and take it out with splice; if there is none, throw an Error with the message <name> is not in the inventory. The Remove buttons already call it and render again. The starter’s remove does nothing, so the rows stay.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads the JavaScript runner (up to 0.1 MB) and keeps it cached. Your code runs in your browser’s own engine and stays on your device.

Your page

Your page runs in an isolated frame. It has no access to this site, and its localStorage is kept in memory for this run only: the next run starts empty.

Run the program to see its page here.

Hints
  1. Hint 1

    Run the tests: the Notebook row is still there. What does remove() do with the private array?

  2. Hint 2

    findIndex((item) => item.name === name) gives the position, or -1 when there is no such item.

  3. Hint 3

    if (index === -1) throw new Error(`${name} is not in the inventory`); this.#items.splice(index, 1);

Show a solution

One way to solve it. Yours can look different and still pass the checks.

class Item {
  #name;
  #price;
  #quantity;

  constructor(name, price, quantity) {
    if (typeof name !== 'string' || name.trim() === '') throw new TypeError('name must not be empty');
    if (!(price >= 0)) throw new RangeError(`invalid price: ${price}`);
    if (!Number.isInteger(quantity) || quantity < 0) throw new RangeError(`invalid quantity: ${quantity}`);
    this.#name = name.trim();
    this.#price = price;
    this.#quantity = quantity;
  }

  get name() {
    return this.#name;
  }

  get price() {
    return this.#price;
  }

  get quantity() {
    return this.#quantity;
  }

  get value() {
    return this.#price * this.#quantity;
  }
}

class Inventory {
  #items = [];

  static fromJSON(text) {
    const inventory = new Inventory();
    for (const { name, price, quantity } of JSON.parse(text)) {
      inventory.add(new Item(name, price, quantity));
    }
    return inventory;
  }

  add(item) {
    if (!(item instanceof Item)) throw new TypeError('only an Item can be added');
    if (this.#items.some((other) => other.name === item.name)) {
      throw new Error(`${item.name} is already in the inventory`);
    }
    this.#items.push(item);
  }

  remove(name) {
    const index = this.#items.findIndex((item) => item.name === name);
    if (index === -1) throw new Error(`${name} is not in the inventory`);
    this.#items.splice(index, 1);
  }

  get items() {
    return [...this.#items];
  }

  get count() {
    return this.#items.length;
  }

  get totalValue() {
    return this.#items.reduce((sum, item) => sum + item.value, 0);
  }
}

const STOCK = '[{"name": "Pencil", "price": 0.5, "quantity": 40}, {"name": "Notebook", "price": 2.25, "quantity": 12}, {"name": "Stapler", "price": 7.9, "quantity": 3}]';
const inventory = Inventory.fromJSON(STOCK);

const form = document.querySelector('#add-form');
const nameInput = document.querySelector('#item-name');
const priceInput = document.querySelector('#item-price');
const quantityInput = document.querySelector('#item-quantity');
const rows = document.querySelector('#rows');
const message = document.querySelector('#message');
const summary = document.querySelector('#summary');

const euro = (amount) => `${amount.toFixed(2)} EUR`;

function cell(content) {
  const td = document.createElement('td');
  td.append(content);
  return td;
}

// The table is always rebuilt from the model.
function render() {
  rows.replaceChildren(
    ...inventory.items.map((item) => {
      const remove = document.createElement('button');
      remove.type = 'button';
      remove.textContent = 'Remove';
      remove.dataset.name = item.name;
      const row = document.createElement('tr');
      row.append(cell(item.name), cell(euro(item.price)), cell(String(item.quantity)), cell(euro(item.value)), cell(remove));
      return row;
    })
  );
  summary.textContent = `${inventory.count} items, total value ${euro(inventory.totalValue)}`;
}

form.addEventListener('submit', (event) => {
  event.preventDefault();
  try {
    const item = new Item(nameInput.value, Number(priceInput.value), Number(quantityInput.value));
    inventory.add(item);
    message.textContent = `Added ${item.name}.`;
    form.reset();
  } catch (error) {
    message.textContent = error.message;
  }
  render();
});

rows.addEventListener('click', (event) => {
  const button = event.target.closest('button');
  if (!button) return;
  try {
    inventory.remove(button.dataset.name);
    message.textContent = `Removed ${button.dataset.name}.`;
  } catch (error) {
    message.textContent = error.message;
  }
  render();
});

render();
Run it on your computer

Install ECMAScript 2026 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.js

class Item {
  #name;
  #price;
  #quantity;

  constructor(name, price, quantity) {
    if (typeof name !== 'string' || name.trim() === '') throw new TypeError('name must not be empty');
    if (!(price >= 0)) throw new RangeError(`invalid price: ${price}`);
    if (!Number.isInteger(quantity) || quantity < 0) throw new RangeError(`invalid quantity: ${quantity}`);
    this.#name = name.trim();
    this.#price = price;
    this.#quantity = quantity;
  }

  get name() {
    return this.#name;
  }

  get price() {
    return this.#price;
  }

  get quantity() {
    return this.#quantity;
  }

  get value() {
    return this.#price * this.#quantity;
  }
}

class Inventory {
  #items = [];

  static fromJSON(text) {
    const inventory = new Inventory();
    for (const { name, price, quantity } of JSON.parse(text)) {
      inventory.add(new Item(name, price, quantity));
    }
    return inventory;
  }

  add(item) {
    if (!(item instanceof Item)) throw new TypeError('only an Item can be added');
    if (this.#items.some((other) => other.name === item.name)) {
      throw new Error(`${item.name} is already in the inventory`);
    }
    this.#items.push(item);
  }

  remove(name) {
    // TODO: find the item by its name and take it out; throw an Error if there is none.
  }

  get items() {
    return [...this.#items];
  }

  get count() {
    return this.#items.length;
  }

  get totalValue() {
    return this.#items.reduce((sum, item) => sum + item.value, 0);
  }
}

const STOCK = '[{"name": "Pencil", "price": 0.5, "quantity": 40}, {"name": "Notebook", "price": 2.25, "quantity": 12}, {"name": "Stapler", "price": 7.9, "quantity": 3}]';
const inventory = Inventory.fromJSON(STOCK);

const form = document.querySelector('#add-form');
const nameInput = document.querySelector('#item-name');
const priceInput = document.querySelector('#item-price');
const quantityInput = document.querySelector('#item-quantity');
const rows = document.querySelector('#rows');
const message = document.querySelector('#message');
const summary = document.querySelector('#summary');

const euro = (amount) => `${amount.toFixed(2)} EUR`;

function cell(content) {
  const td = document.createElement('td');
  td.append(content);
  return td;
}

// The table is always rebuilt from the model.
function render() {
  rows.replaceChildren(
    ...inventory.items.map((item) => {
      const remove = document.createElement('button');
      remove.type = 'button';
      remove.textContent = 'Remove';
      remove.dataset.name = item.name;
      const row = document.createElement('tr');
      row.append(cell(item.name), cell(euro(item.price)), cell(String(item.quantity)), cell(euro(item.value)), cell(remove));
      return row;
    })
  );
  summary.textContent = `${inventory.count} items, total value ${euro(inventory.totalValue)}`;
}

form.addEventListener('submit', (event) => {
  event.preventDefault();
  try {
    const item = new Item(nameInput.value, Number(priceInput.value), Number(quantityInput.value));
    inventory.add(item);
    message.textContent = `Added ${item.name}.`;
    form.reset();
  } catch (error) {
    message.textContent = error.message;
  }
  render();
});

rows.addEventListener('click', (event) => {
  const button = event.target.closest('button');
  if (!button) return;
  try {
    inventory.remove(button.dataset.name);
    message.textContent = `Removed ${button.dataset.name}.`;
  } catch (error) {
    message.textContent = error.message;
  }
  render();
});

render();

main.test.js

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {loadPage} from './learnrun.js';

const text = (page, selector) => page.document.querySelector(selector).textContent;
const names = (page) => [...page.document.querySelectorAll('#rows tr')].map((row) => row.firstChild.textContent);
function submit(page, name, price, quantity) {
  page.document.querySelector('#item-name').value = name;
  page.document.querySelector('#item-price').value = price;
  page.document.querySelector('#item-quantity').value = quantity;
  page.document.querySelector('#add-form button').click();
}
function removeButton(page, name) {
  const button = page.document.querySelector(`#rows button[data-name="${name}"]`);
  assert.ok(button, `there is no Remove button for ${name}; the rows are ${JSON.stringify(names(page))}`);
  return button;
}

test('Remove takes the row away', async () => {
  const page = await loadPage();
  removeButton(page, 'Notebook').click();
  assert.deepEqual(names(page), ['Pencil', 'Stapler'], 'the rows are ' + JSON.stringify(names(page)));
  assert.equal(text(page, '#message'), 'Removed Notebook.', 'the message is ' + text(page, '#message'));
});

test('The summary follows the removal', async () => {
  const page = await loadPage();
  removeButton(page, 'Notebook').click();
  assert.equal(text(page, '#summary'), '2 items, total value 43.70 EUR', 'the summary is ' + text(page, '#summary'));
});

test('A removed item can be added again', async () => {
  const page = await loadPage();
  removeButton(page, 'Pencil').click();
  submit(page, 'Pencil', '0.6', '10');
  assert.equal(text(page, '#message'), 'Added Pencil.', 'the message is ' + text(page, '#message'));
});

index.html

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Inventory</title>
</head>
<body>
  <h1>Inventory</h1>
  <form id="add-form" novalidate>
    <label>Name <input id="item-name"></label>
    <label>Price <input id="item-price" type="number" step="0.01"></label>
    <label>Quantity <input id="item-quantity" type="number"></label>
    <button type="submit">Add</button>
  </form>
  <p id="message"></p>
  <table>
    <thead>
      <tr><th>Item</th><th>Price</th><th>Quantity</th><th>Value</th><th></th></tr>
    </thead>
    <tbody id="rows"></tbody>
  </table>
  <p id="summary"></p>
  <script type="module" src="main.js"></script>
</body>
</html>

package.json

{
  "type": "module"
}

package.json tells Node.js that the .js files are modules; keep it in the folder.

Run the program:

To run the page on your computer, open index.html through a local web server, not as a file: browsers do not run module scripts from file:// addresses. For the checks, install jsdom first (the commands below).

Run the checks (needs learnrun.js in the same folder):

npm install jsdom@28.1.0
node --test
Download learnrun.js

Exercise 3 of 3

Load the stock with a static factory

Finish the static factory Inventory.fromJSON(text). The page calls it with a JSON text of three items. It must parse the text with JSON.parse and add one new Item(name, price, quantity) for each entry to the new inventory before it returns it. The starter returns an empty inventory, so the table starts empty.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads the JavaScript runner (up to 0.1 MB) and keeps it cached. Your code runs in your browser’s own engine and stays on your device.

Your page

Your page runs in an isolated frame. It has no access to this site, and its localStorage is kept in memory for this run only: the next run starts empty.

Run the program to see its page here.

Hints
  1. Hint 1

    Run the tests: the table is empty. What does the starter’s fromJSON put into the inventory?

  2. Hint 2

    JSON.parse(text) gives an array of plain objects with name, price and quantity.

  3. Hint 3

    for (const { name, price, quantity } of JSON.parse(text)) inventory.add(new Item(name, price, quantity));

Show a solution

One way to solve it. Yours can look different and still pass the checks.

class Item {
  #name;
  #price;
  #quantity;

  constructor(name, price, quantity) {
    if (typeof name !== 'string' || name.trim() === '') throw new TypeError('name must not be empty');
    if (!(price >= 0)) throw new RangeError(`invalid price: ${price}`);
    if (!Number.isInteger(quantity) || quantity < 0) throw new RangeError(`invalid quantity: ${quantity}`);
    this.#name = name.trim();
    this.#price = price;
    this.#quantity = quantity;
  }

  get name() {
    return this.#name;
  }

  get price() {
    return this.#price;
  }

  get quantity() {
    return this.#quantity;
  }

  get value() {
    return this.#price * this.#quantity;
  }
}

class Inventory {
  #items = [];

  static fromJSON(text) {
    const inventory = new Inventory();
    for (const { name, price, quantity } of JSON.parse(text)) {
      inventory.add(new Item(name, price, quantity));
    }
    return inventory;
  }

  add(item) {
    if (!(item instanceof Item)) throw new TypeError('only an Item can be added');
    if (this.#items.some((other) => other.name === item.name)) {
      throw new Error(`${item.name} is already in the inventory`);
    }
    this.#items.push(item);
  }

  remove(name) {
    const index = this.#items.findIndex((item) => item.name === name);
    if (index === -1) throw new Error(`${name} is not in the inventory`);
    this.#items.splice(index, 1);
  }

  get items() {
    return [...this.#items];
  }

  get count() {
    return this.#items.length;
  }

  get totalValue() {
    return this.#items.reduce((sum, item) => sum + item.value, 0);
  }
}

const STOCK = '[{"name": "Pencil", "price": 0.5, "quantity": 40}, {"name": "Notebook", "price": 2.25, "quantity": 12}, {"name": "Stapler", "price": 7.9, "quantity": 3}]';
const inventory = Inventory.fromJSON(STOCK);

const form = document.querySelector('#add-form');
const nameInput = document.querySelector('#item-name');
const priceInput = document.querySelector('#item-price');
const quantityInput = document.querySelector('#item-quantity');
const rows = document.querySelector('#rows');
const message = document.querySelector('#message');
const summary = document.querySelector('#summary');

const euro = (amount) => `${amount.toFixed(2)} EUR`;

function cell(content) {
  const td = document.createElement('td');
  td.append(content);
  return td;
}

// The table is always rebuilt from the model.
function render() {
  rows.replaceChildren(
    ...inventory.items.map((item) => {
      const remove = document.createElement('button');
      remove.type = 'button';
      remove.textContent = 'Remove';
      remove.dataset.name = item.name;
      const row = document.createElement('tr');
      row.append(cell(item.name), cell(euro(item.price)), cell(String(item.quantity)), cell(euro(item.value)), cell(remove));
      return row;
    })
  );
  summary.textContent = `${inventory.count} items, total value ${euro(inventory.totalValue)}`;
}

form.addEventListener('submit', (event) => {
  event.preventDefault();
  try {
    const item = new Item(nameInput.value, Number(priceInput.value), Number(quantityInput.value));
    inventory.add(item);
    message.textContent = `Added ${item.name}.`;
    form.reset();
  } catch (error) {
    message.textContent = error.message;
  }
  render();
});

rows.addEventListener('click', (event) => {
  const button = event.target.closest('button');
  if (!button) return;
  try {
    inventory.remove(button.dataset.name);
    message.textContent = `Removed ${button.dataset.name}.`;
  } catch (error) {
    message.textContent = error.message;
  }
  render();
});

render();
Run it on your computer

Install ECMAScript 2026 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.js

class Item {
  #name;
  #price;
  #quantity;

  constructor(name, price, quantity) {
    if (typeof name !== 'string' || name.trim() === '') throw new TypeError('name must not be empty');
    if (!(price >= 0)) throw new RangeError(`invalid price: ${price}`);
    if (!Number.isInteger(quantity) || quantity < 0) throw new RangeError(`invalid quantity: ${quantity}`);
    this.#name = name.trim();
    this.#price = price;
    this.#quantity = quantity;
  }

  get name() {
    return this.#name;
  }

  get price() {
    return this.#price;
  }

  get quantity() {
    return this.#quantity;
  }

  get value() {
    return this.#price * this.#quantity;
  }
}

class Inventory {
  #items = [];

  static fromJSON(text) {
    const inventory = new Inventory();
    // TODO: parse text and add one Item for each entry.
    return inventory;
  }

  add(item) {
    if (!(item instanceof Item)) throw new TypeError('only an Item can be added');
    if (this.#items.some((other) => other.name === item.name)) {
      throw new Error(`${item.name} is already in the inventory`);
    }
    this.#items.push(item);
  }

  remove(name) {
    const index = this.#items.findIndex((item) => item.name === name);
    if (index === -1) throw new Error(`${name} is not in the inventory`);
    this.#items.splice(index, 1);
  }

  get items() {
    return [...this.#items];
  }

  get count() {
    return this.#items.length;
  }

  get totalValue() {
    return this.#items.reduce((sum, item) => sum + item.value, 0);
  }
}

const STOCK = '[{"name": "Pencil", "price": 0.5, "quantity": 40}, {"name": "Notebook", "price": 2.25, "quantity": 12}, {"name": "Stapler", "price": 7.9, "quantity": 3}]';
const inventory = Inventory.fromJSON(STOCK);

const form = document.querySelector('#add-form');
const nameInput = document.querySelector('#item-name');
const priceInput = document.querySelector('#item-price');
const quantityInput = document.querySelector('#item-quantity');
const rows = document.querySelector('#rows');
const message = document.querySelector('#message');
const summary = document.querySelector('#summary');

const euro = (amount) => `${amount.toFixed(2)} EUR`;

function cell(content) {
  const td = document.createElement('td');
  td.append(content);
  return td;
}

// The table is always rebuilt from the model.
function render() {
  rows.replaceChildren(
    ...inventory.items.map((item) => {
      const remove = document.createElement('button');
      remove.type = 'button';
      remove.textContent = 'Remove';
      remove.dataset.name = item.name;
      const row = document.createElement('tr');
      row.append(cell(item.name), cell(euro(item.price)), cell(String(item.quantity)), cell(euro(item.value)), cell(remove));
      return row;
    })
  );
  summary.textContent = `${inventory.count} items, total value ${euro(inventory.totalValue)}`;
}

form.addEventListener('submit', (event) => {
  event.preventDefault();
  try {
    const item = new Item(nameInput.value, Number(priceInput.value), Number(quantityInput.value));
    inventory.add(item);
    message.textContent = `Added ${item.name}.`;
    form.reset();
  } catch (error) {
    message.textContent = error.message;
  }
  render();
});

rows.addEventListener('click', (event) => {
  const button = event.target.closest('button');
  if (!button) return;
  try {
    inventory.remove(button.dataset.name);
    message.textContent = `Removed ${button.dataset.name}.`;
  } catch (error) {
    message.textContent = error.message;
  }
  render();
});

render();

main.test.js

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {loadPage} from './learnrun.js';

const text = (page, selector) => page.document.querySelector(selector).textContent;
const names = (page) => [...page.document.querySelectorAll('#rows tr')].map((row) => row.firstChild.textContent);
function submit(page, name, price, quantity) {
  page.document.querySelector('#item-name').value = name;
  page.document.querySelector('#item-price').value = price;
  page.document.querySelector('#item-quantity').value = quantity;
  page.document.querySelector('#add-form button').click();
}
function removeButton(page, name) {
  const button = page.document.querySelector(`#rows button[data-name="${name}"]`);
  assert.ok(button, `there is no Remove button for ${name}; the rows are ${JSON.stringify(names(page))}`);
  return button;
}

test('The page starts with the three items of the stock', async () => {
  const page = await loadPage();
  assert.deepEqual(names(page), ['Pencil', 'Notebook', 'Stapler'], 'the rows are ' + JSON.stringify(names(page)));
});

test('The summary shows the count and the total value', async () => {
  const page = await loadPage();
  assert.equal(text(page, '#summary'), '3 items, total value 70.70 EUR', 'the summary is ' + text(page, '#summary'));
});

test('The value column is price times quantity', async () => {
  const page = await loadPage();
  const row = removeButton(page, 'Stapler').closest('tr');
  const cells = [...row.children].map((td) => td.textContent);
  assert.deepEqual(cells.slice(0, 4), ['Stapler', '7.90 EUR', '3', '23.70 EUR'], 'the Stapler row is ' + JSON.stringify(cells));
});

index.html

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Inventory</title>
</head>
<body>
  <h1>Inventory</h1>
  <form id="add-form" novalidate>
    <label>Name <input id="item-name"></label>
    <label>Price <input id="item-price" type="number" step="0.01"></label>
    <label>Quantity <input id="item-quantity" type="number"></label>
    <button type="submit">Add</button>
  </form>
  <p id="message"></p>
  <table>
    <thead>
      <tr><th>Item</th><th>Price</th><th>Quantity</th><th>Value</th><th></th></tr>
    </thead>
    <tbody id="rows"></tbody>
  </table>
  <p id="summary"></p>
  <script type="module" src="main.js"></script>
</body>
</html>

package.json

{
  "type": "module"
}

package.json tells Node.js that the .js files are modules; keep it in the folder.

Run the program:

To run the page on your computer, open index.html through a local web server, not as a file: browsers do not run module scripts from file:// addresses. For the checks, install jsdom first (the commands below).

Run the checks (needs learnrun.js in the same folder):

npm install jsdom@28.1.0
node --test
Download learnrun.js

Common mistakes

Adding plain data instead of an Item

class Item {
  #name;
  constructor(name) {
    this.#name = name;
  }
  get name() {
    return this.#name;
  }
}
class Inventory {
  #items = [];
  add(item) {
    if (!(item instanceof Item)) throw new TypeError("only an Item can be added");
    this.#items.push(item);
  }
}
const inventory = new Inventory();
inventory.add({name: "Pencil", price: 0.5, quantity: 40});

What Node.js prints

TypeError: only an Item can be added

Why, and the fix

The object literal has the right properties, but it was never checked by Item’s constructor, so the inventory refuses it with its own TypeError. Build the item first: inventory.add(new Item("Pencil", 0.5, 40)). That is also why fromJSON calls new Item for every entry instead of adding the parsed objects.

Changing a getter-only property from outside

class Item {
  #quantity;
  constructor(quantity) {
    this.#quantity = quantity;
  }
  get quantity() {
    return this.#quantity;
  }
}
const pencil = new Item(40);
pencil.quantity = 39;

What Node.js prints

TypeError: Cannot set property quantity of #<Item> which has only a getter

Why, and the fix

quantity has a getter but no setter, so it is read-only, and in a module the assignment throws. That is on purpose: changes should go through a method that checks them. Add one, such as take(count), which throws when count is more than #quantity and otherwise lowers it.

Calling the factory on an instance

class Inventory {
  #items = [];
  static fromJSON(text) {
    const inventory = new Inventory();
    inventory.#items.push(...JSON.parse(text));
    return inventory;
  }
}
const empty = new Inventory();
const stock = empty.fromJSON('[{"name": "Pencil"}]');

What Node.js prints

TypeError: empty.fromJSON is not a function

Why, and the fix

fromJSON is static, so it belongs to the class Inventory and not to its instances. You do not need an inventory to make an inventory: call Inventory.fromJSON('[{"name": "Pencil"}]').

JavaScript in the browser: your browser’s own engine, in a sandboxed worker. Syntax errors are located with acorn 8.18.0, MIT. Licence and source

Exit ticket

5 questions, no hints. Score 80% or more to complete the lesson.

Finish every activity above to unlock the exit ticket.

Report a problem

Spotted something wrong or unclear? Say what, and it will be checked and fixed.

#

At least 20 characters.

Only if you want a reply.

Key ideas

Two classes hold the data

Item keeps name, price and quantity in private fields and offers them through getters, so nothing outside can change an item after it was made. Its getter value computes price × quantity on every read. Inventory keeps its items in a private #items array. Its getter items returns a copy, [...this.#items], because returning the array itself would let any code push into it. count and totalValue are getters too. The static factory Inventory.fromJSON(text) parses JSON and adds one Item per entry.

The model refuses bad data

The checks live in the classes, not in the page. The constructor of Item throws a TypeError for an empty name and a RangeError for a price that is not >= 0 or a quantity that is not a whole number. !(price >= 0) also catches NaN, which price < 0 would let through. Inventory’s add throws for a name it already has, remove for a name it does not have. The page wraps each change in try...catch and shows error.message, so a refused item never reaches the table.

One source of truth

The page never edits a table row directly. Every change goes to the inventory first; then render() rebuilds all rows and the summary from inventory.items with replaceChildren, so the table always shows what the model holds. The form’s submit listener calls preventDefault, builds an Item from the inputs and adds it. One click listener on the table body handles every Remove button: closest('button') finds the button, and its data-name says which item to remove.

Sources

Last reviewed October 5, 2026