All exercises use the tea shop data. Start by loading it:
import { teas } from "../../data/teas.js";A class is a blueprint for creating objects. The constructor initializes each instance.
📚 Recall: constructor and this
The constructor method runs when you create an instance with new. Inside it, this refers to the new object being created. this.name = name stores the parameter on the instance.
Create a Tea class with a constructor that accepts name, type, and origin. Create two instances and log them.
class Tea {
// your constructor
}
const sencha = new Tea("Sencha", "green", "Japan");
const earlGrey = new Tea("Earl Grey", "black", "India");
console.log(sencha.name); // "Sencha"
console.log(sencha.type); // "green"
console.log(earlGrey.origin); // "India"Extend your Tea class to also accept pricePerGram and organic (boolean). Create an instance from the first tea in the data array.
const firstTea = teas[0];
const tea = new Tea(
firstTea.name,
firstTea.type,
firstTea.origin,
firstTea.pricePerGram,
firstTea.organic,
);
console.log(tea);Create the Tea instances using .map() and your class:
const teaInstances = teas.map(
(t) => new Tea(t.name, t.type, t.origin, t.pricePerGram, t.organic),
);
console.log(teaInstances.length); // 20
console.log(teaInstances[0].name); // "Sencha"Add validation to your constructor. Throw an error if:
nameis empty or missingpricePerGramis negativetypeis not one of: "green", "black", "herbal", "oolong", "white"
// Should work:
const valid = new Tea("Sencha", "green", "Japan", 0.12, true);
// Should throw:
const noName = new Tea("", "green", "Japan", 0.12, true);
// Error: "Name is required"
const badPrice = new Tea("Sencha", "green", "Japan", -1, true);
// Error: "Price must be positive"
const badType = new Tea("Sencha", "purple", "Japan", 0.12, true);
// Error: "Invalid type: purple"Methods are functions that belong to a class. They use this to access the instance's data.
📚 Recall: encapsulation
Encapsulation means bundling data and the operations that work on it together. Instead of separate functions that take a tea object as a parameter, the tea itself has methods.
Add a priceFor(grams) method to your Tea class that returns the price for a given weight.
const sencha = new Tea("Sencha", "green", "Japan", 0.12, true);
console.log(sencha.priceFor(100)); // 12
console.log(sencha.priceFor(50)); // 6Add a describe() method that returns a formatted string:
const sencha = new Tea("Sencha", "green", "Japan", 0.12, true);
console.log(sencha.describe());
// "Sencha (green) from Japan - 12.00 DKK/100g"
const earlGrey = new Tea("Earl Grey", "black", "India", 0.08, false);
console.log(earlGrey.describe());
// "Earl Grey (black) from India - 8.00 DKK/100g"Hint: Use (this.pricePerGram * 100).toFixed(2) for the price.
Create an OrderItem class that takes a Tea instance and a number of grams. Add a lineTotal() method.
class OrderItem {
constructor(tea, grams) {
// store the tea instance and grams
}
lineTotal() {
// use the tea's priceFor method
}
}
const sencha = new Tea("Sencha", "green", "Japan", 0.12, true);
const item = new OrderItem(sencha, 200);
console.log(item.tea.name); // "Sencha"
console.log(item.grams); // 200
console.log(item.lineTotal()); // 24💡 Notice: OrderItem uses a Tea instance - it doesn't extend it. This is composition: one class containing another.
Add a describe() method to OrderItem that returns a formatted line:
const item = new OrderItem(sencha, 200);
console.log(item.describe());
// "200g Sencha - 24.00 DKK"Then create several order items from the tea data and use .map() to log all descriptions:
const items = [
new OrderItem(teaInstances[0], 100),
new OrderItem(teaInstances[1], 200),
new OrderItem(teaInstances[7], 50),
];
items.map((item) => item.describe()).forEach((line) => console.log(line));
// "100g Sencha - 12.00 DKK"
// "200g Earl Grey - 16.00 DKK"
// "50g Matcha - 22.50 DKK"Methods can modify instance state, not just read it. this is how you access and change an object's data.
📚 Recall: this
Inside a class, this refers to the current instance. this.name reads the instance's name. this.stockCount -= 10 modifies it. Each instance has its own state.
Create an Inventory class that tracks stock for a tea. It should have sell(grams) and restock(grams) methods.
class Inventory {
constructor(tea, stockCount) {
this.tea = tea;
this.stockCount = stockCount;
}
sell(grams) {
// Subtract grams from stockCount
// Throw an error if not enough stock
}
restock(grams) {
// Add grams to stockCount
}
}
const sencha = new Tea("Sencha", "green", "Japan", 0.12, true);
const stock = new Inventory(sencha, 150);
console.log(stock.stockCount); // 150
stock.sell(50);
console.log(stock.stockCount); // 100
stock.restock(200);
console.log(stock.stockCount); // 300
stock.sell(500); // Error: "Not enough stock for Sencha (have 300, need 500)"Create an Order class with status transitions. An order starts as "pending" and can move through: pending → confirmed → shipped → delivered.
class Order {
constructor() {
this.items = [];
this.status = "pending";
}
addItem(orderItem) {
// Only allow adding items when status is "pending"
// Throw error otherwise
}
confirm() {
// Change status to "confirmed" (only from "pending")
}
ship() {
// Change status to "shipped" (only from "confirmed")
}
deliver() {
// Change status to "delivered" (only from "shipped")
}
}
const order = new Order();
const sencha = new Tea("Sencha", "green", "Japan", 0.12, true);
order.addItem(new OrderItem(sencha, 100));
console.log(order.status); // "pending"
order.confirm();
console.log(order.status); // "confirmed"
order.addItem(new OrderItem(sencha, 50));
// Error: "Cannot add items to a confirmed order"
order.ship();
order.deliver();
console.log(order.status); // "delivered"Add a getTotal() method to your Order class that uses .reduce() to sum all item totals:
const order = new Order();
order.addItem(
new OrderItem(new Tea("Sencha", "green", "Japan", 0.12, true), 100),
);
order.addItem(
new OrderItem(new Tea("Matcha", "green", "Japan", 0.45, true), 50),
);
console.log(order.getTotal()); // 34.5 (12 + 22.5)Also add a getSummary() method that returns a formatted order summary:
console.log(order.getSummary());
// Order (pending) - 2 items
// - 100g Sencha - 12.00 DKK
// - 50g Matcha - 22.50 DKK
// Total: 34.50 DKKReal applications have multiple classes that work together. Each class handles its own responsibility.
📚 Recall: composition
Composition means one class uses instances of another class. An Order contains OrderItems. A Catalog contains Teas. Each class handles its own job and delegates to others.
Create a TeaCatalog class that holds Tea instances and provides search/filter methods:
class TeaCatalog {
constructor(teas) {
// Store the array of Tea instances
}
search(query) {
// Return teas where name includes query (case-insensitive)
}
filterByType(type) {
// Return teas matching the type
}
}
const catalog = new TeaCatalog(
teas.map((t) => new Tea(t.name, t.type, t.origin, t.pricePerGram, t.organic)),
);
console.log(catalog.search("earl"));
// [Tea { name: "Earl Grey", ... }]
console.log(catalog.filterByType("green").map((t) => t.name));
// ["Sencha", "Dragon Well", "Matcha", "Genmaicha", "Jasmine Pearl", ...]Create a Customer class that can place orders:
class Customer {
constructor(name, email) {
this.name = name;
this.email = email;
this.orders = [];
}
placeOrder(order) {
// Add the order to this.orders
// Confirm the order
// Return the order
}
totalSpent() {
// Use .reduce() to sum getTotal() across all orders
}
}
const customer = new Customer("Alex", "alex@example.com");
const order1 = new Order();
order1.addItem(
new OrderItem(new Tea("Sencha", "green", "Japan", 0.12, true), 100),
);
customer.placeOrder(order1);
const order2 = new Order();
order2.addItem(
new OrderItem(new Tea("Matcha", "green", "Japan", 0.45, true), 50),
);
customer.placeOrder(order2);
console.log(customer.orders.length); // 2
console.log(customer.totalSpent()); // 34.5Bring it together: create a catalog, find teas, create an order, and assign it to a customer.
// 1. Create a TeaCatalog from the tea data
const catalog = new TeaCatalog(
teas.map((t) => new Tea(t.name, t.type, t.origin, t.pricePerGram, t.organic)),
);
// 2. Find all Japanese teas
const japaneseTeas = catalog.search("").filter((t) => t.origin === "Japan");
// 3. Create an order with 100g of each Japanese tea
const order = new Order();
japaneseTeas.forEach((tea) => {
order.addItem(new OrderItem(tea, 100));
});
// 4. Create a customer and place the order
const customer = new Customer("Tea Lover", "lover@tea.com");
customer.placeOrder(order);
// 5. Log the summary
console.log(`${customer.name} ordered ${order.items.length} Japanese teas`);
console.log(`Total: ${customer.totalSpent().toFixed(2)} DKK`);Static methods belong to the class itself, not to instances. Use them for factory methods and utility operations.
📚 Recall: static methods
A static method is called on the class: Tea.fromObject(data). An instance method is called on an object: sencha.priceFor(100). Static methods don't have access to this as an instance.
Add a static fromObject(obj) factory method to your Tea class that creates a Tea from a plain object:
class Tea {
// ... existing code ...
static fromObject(obj) {
// Create and return a new Tea from a plain object
}
}
// Convert all plain objects to Tea instances in one line:
const teaInstances = teas.map(Tea.fromObject);
console.log(teaInstances[0].describe());
// "Sencha (green) from Japan - 12.00 DKK/100g"
console.log(teaInstances[0].priceFor(100));
// 12💡 This pattern is common on backends: data comes from a database or API as plain objects, and a factory method converts them to class instances.
Add these static utility methods to your Tea class:
class Tea {
// ... existing code ...
static findCheapest(teas) {
// Return the tea with the lowest pricePerGram
}
static findMostExpensive(teas) {
// Return the tea with the highest pricePerGram
}
static averagePrice(teas) {
// Return the average pricePerGram across all teas
}
}
const teaInstances = teas.map(Tea.fromObject);
console.log(Tea.findCheapest(teaInstances).name);
// "English Breakfast"
console.log(Tea.findMostExpensive(teaInstances).name);
// "Gyokuro"
console.log(Tea.averagePrice(teaInstances).toFixed(2));
// "0.22"Hint: findCheapest and findMostExpensive can use .reduce().
Inheritance lets one class build on another. The child class gets all parent methods and can add or override them.
📚 Recall: extends and super()
extends creates a child class from a parent. super() in the constructor calls the parent's constructor. super.method() calls the parent's version of an overridden method.
Create a PremiumTea class that extends Tea:
class PremiumTea extends Tea {
constructor(name, type, origin, pricePerGram, organic, grade) {
// Call parent constructor with super()
// Store grade ("A", "B", or "C")
}
priceFor(grams) {
// A = 50% markup, B = 25% markup, C = 10% markup
// Use super.priceFor(grams) to get the base price
}
describe() {
// Override to include grade
// "Gyokuro [Grade A] (green) from Japan - 84.00 DKK/100g"
}
}
const gyokuro = new PremiumTea("Gyokuro", "green", "Japan", 0.56, false, "A");
console.log(gyokuro.describe());
// "Gyokuro [Grade A] (green) from Japan - 84.00 DKK/100g"
console.log(gyokuro.priceFor(100));
// 84 (56 * 1.5)
// It's still a Tea:
console.log(gyokuro instanceof Tea); // true
console.log(gyokuro instanceof PremiumTea); // trueTest with different grades:
const gradeB = new PremiumTea(
"Silver Needle",
"white",
"China",
0.5,
true,
"B",
);
console.log(gradeB.priceFor(100)); // 62.5 (50 * 1.25)
const gradeC = new PremiumTea("Darjeeling", "black", "India", 0.18, false, "C");
console.log(gradeC.priceFor(100)); // 19.8 (18 * 1.1)Bonus: Add a static fromTea(tea, grade) method to PremiumTea that upgrades an existing Tea to a PremiumTea.