completefrontend
Open lab My progress
THE JAVASCRIPT FIELD GUIDE

OOP principles in JavaScript

Use encapsulation, abstraction, polymorphism, factories, classes, and practical SOLID rules to design JavaScript objects that are easy to change and test.

By the end you can
  • 01
    Give objects a small public interfaceHide an object's data with private fields or closures, and offer a few methods that check every change.
  • 02
    Use polymorphismWrite a loop that calls a method by name and works with any object that has that method, whatever its class.
  • 03
    Choose a factory or a class, and use SOLIDCompare the two main ways to make objects, and use the five SOLID rules to make code easier to change and test.

What OOP means in JavaScript

Object-oriented programming, or OOP, is a way to organize code around objects. An object holds some data, and the methods (functions) that work on that data. You don't have to use OOP to write JavaScript. You can also build good programs with plain functions, modules, closures, and plain objects. Most real programs mix all of these.

The useful core of OOP is small. An object offers a short list of methods that other code may call. That list is the object's public interface. Other code uses only that list. Everything else stays hidden inside the object.

Here are two objects from this lesson:

  • A Thermostat offers setTarget() and display.
  • A Circle, a Square, and a Triangle all offer area() and draw().

Code that uses these objects calls only those methods. It doesn't need the hidden parts: private fields, maths formulas, or how the object stores its data.

OOP in JavaScript, in short

In JavaScript, OOP means designing objects in three ways:

  • Give each object a small public interface: a short list of methods.
  • Hide the data that other code should not change directly.
  • Let different objects answer the same method call, each in its own way.

A class is one tool for making such objects. Factory functions, closures, delegation, and mixins are tools too.

This lesson ends the Classes module. You have already seen:

  • class syntax;
  • extends and super;
  • static members;
  • #private fields;
  • extending built-in classes;
  • mixins.

Two earlier lessons help here too: Closure patterns and Delegation vs classical inheritance. They show that JavaScript doesn't have to copy the class style of languages like Java. Stage 9 goes further, with Creational patterns and Structuring an application. This lesson stays practical, with code you can run.

Real-life analogySOLID rules are kitchen rules

A busy kitchen gets messy when everyone works at every station. It gets messy when one tool tries to do every job. It gets messy when a new pan behaves in a surprising way. Good kitchens follow a few simple rules to stop this.

SOLID is a set of five rules like these, for code. Each letter is the first letter of one rule. You'll meet each rule later in this lesson. They are reminders that help when a program grows, not laws to follow every time.

In real life: One cook looks after the sauce station
In JavaScript: Single Responsibility: each piece of code has one job
In real life: Add a new dish without rewriting every order ticket
In JavaScript: Open/Closed: add new kinds of things without editing code that works
In real life: A new pan must still work on the same stove
In JavaScript: Liskov Substitution: a replacement object keeps the same promises
In real life: Use small tools, not one giant gadget
In JavaScript: Interface Segregation: ask only for the methods you use
In real life: Plug in any mixer that fits the socket
In JavaScript: Dependency Inversion: be given your tools instead of building them in

Encapsulation & abstraction

STEP THROUGH

Encapsulation means keeping data, and the methods that look after it, together inside one object. The object protects its own rules. A rule here is something that must always be true, like "the target temperature is between 10 and 35".

JavaScript gives you several ways to hide data inside an object:

  • #private fields and methods in a class. Only code inside the class body can use them.
  • Variables in a closure. A closure is a function that keeps using the variables from the place where it was made.
  • Variables in a module that the module doesn't export.
  • A naming habit, like starting a property name with _. This only asks people not to touch it. It doesn't stop them.

Which way you choose matters less than the result: outside code gets fewer ways to break the object's rules.

Abstraction means giving other code a simple idea to use, and hiding the steps behind it. Other code should just write thermostat.setTarget(22). It should not have to check the range, store the number, and build the display text itself. The public method is the simple idea. The private field and the checks are the hidden steps.

Outside code can't read a private fieldPop out in the code editor (opens in a new tab)JavaScript
class Wallet {
  #balance = 0;

  add(amount) {
    this.#balance += amount;
    return this.#balance;
  }
}

const wallet = new Wallet();
console.log(wallet.add(3));
console.log(wallet.balance);

Line 2 creates a private field, #balance, inside Wallet. Only code inside the class can use it. Line 5 changes it from inside the add() method, so line 11 prints 3. Line 12 asks for wallet.balance, with no #. That is a different, public property. The wallet has no such property, so line 12 prints undefined.

Real-life analogyEncapsulation is like a car dashboard

You drive a car with the steering wheel, the pedals, and the dashboard. You don't reach into the engine while you drive. A well-made class works the same way. It gives you controls for normal use. It keeps the parts that break easily behind a cover.

In real life: The steering wheel and pedals
In JavaScript: Public methods, like setTarget()
In real life: The engine, belts, and fuel pipes
In JavaScript: Private fields, like #targetCelsius
In real life: The speedometer shows your speed
In JavaScript: A getter, like display, shows a value you can read but not change
Real-life analogyAbstraction is like a restaurant menu

A menu gives you simple names: pasta, soup, salad. It doesn't hand you the recipe for boiling water. You say what you want, and the kitchen does the steps.

In real life: You order "pasta"
In JavaScript: You call thermostat.setTarget(22)
In real life: The kitchen boils water, adds salt, and cooks the sauce
In JavaScript: The method's hidden steps: check the range, then store the number
In real life: The menu lists only what you may order
In JavaScript: The public interface lists only what you may call
printArea() needs only one methodPop out in the code editor (opens in a new tab)JavaScript
function printArea(shape) {
  return "Area: " + shape.area();
}

const tile = {
  area() {
    return 12;
  },
  color: "blue",
};

console.log(printArea(tile));

printArea() uses only one method of the object it gets: area(). The tile object also has a color, but printArea() never looks at it. So line 12 prints Area: 12. Because printArea() asks for so little, any object with an area() method works with it.

Now try the thermostat below. It has one private field, #targetCelsius, which starts at 20. It has two public parts:

  • setTarget(value) asks for a new temperature. It accepts only 10 to 35°C.
  • display is a getter: a method you read like a property, with no brackets. It returns the temperature as text.

Pick 22, then 41 or -5, and step through. For 41 and -5, the method says no before it changes anything. The private field stays at 20, because setTarget() is the only way in, and it checks first.

A thermostat checks every change to its private field
Step 0 of 5Ready
Your turn: follow the blue line

Pick a temperature, then step through. The private field can only change through setTarget(), and that method checks first.

Running in
  1. script
Next: line 18
class Thermostat {  #targetCelsius = 20;   setTarget(value) {    if (value < 10 || value > 35) {      return "Refused " + value + "°C";    }     this.#targetCelsius = value;    return "Target is " + this.#targetCelsius + "°C";  }   get display() {    return this.#targetCelsius + "°C";  }} console.log(thermostat.display);console.log(thermostat.setTarget(22));console.log(thermostat.display);
CallStoreChangeResultBlue line: runs next. Ran: already executed.
Recent returnsNothing yet. Start with the blue line.
Choose the temperature to ask for

Only 10 to 35°C is accepted. For any other value, the private field stays the same.

A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.
Left: setTarget(22). The value 22 goes into the thermostat, its private field becomes 22, and it returns Target is 22°C. Right: setTarget(41). The thermostat refuses it, the private field stays 20, and it returns Refused 41°C.setTarget(22)22#targetCelsius: 22thermostatTarget is 22°CsetTarget(41)41#targetCelsius: 20thermostatRefused 41°C
setTarget() is the only way to change #targetCelsius, and it checks the value first. 22 is between 10 and 35, so it is saved; 41 is not, so the field stays 20.
Why not just trust the code that calls it?

The code you write today may use the object correctly. Code written later might not. Encapsulation keeps each rule in one place, so a new button, an import script, or a test helper can't skip it by mistake.

It also makes changes safer. If other code uses only display and setTarget(), the class can change how it stores the temperature later. None of that other code has to change.

Polymorphism: one method call, many answers

INTERACTIVE

Polymorphism means "many forms". In JavaScript the idea is simple. Several objects can have a method with the same name. Code can call that method on any of them. Each object runs its own version.

JavaScript doesn't ask you to declare this anywhere. The code just calls the method, and the object decides what happens.

Real-life analogyPolymorphism is like a universal remote

A universal remote has one power button. Point it at a TV, a speaker, or a lamp, and press it. Each device turns on in its own way. The button stays the same.

In real life: One power button
In JavaScript: One method call: shape.area()
In real life: The TV, the speaker, and the lamp each turn on in their own way
In JavaScript: Circle, Square, and Triangle each run their own area()
In real life: The remote doesn't need to know which device it points at
In JavaScript: The loop doesn't check which class each shape is

The shapes example below also shows a SOLID rule called Open/Closed. It means you can add new kinds of things without editing the code that already works:

  • The loop is closed to changes. It always calls draw() and area().
  • The program is open to new shapes. Add a Triangle with the same two methods, and the loop works with it as it is.

Near the end of the code, the shapes array (a list) holds the shapes. The for...of loop below it visits each shape in turn and calls the same two methods. This is simpler than a chain of checks like if (shape.name === "circle"), which you would have to edit for every new shape.

One loop, many kinds of shape
Step 0 of 5Ready
Your turn: follow the blue line

Guess each printed line first. Then pick Add Triangle above, and see that the loop doesn't change.

Running in
  1. script
Next: line 31
class Circle {  constructor(radius) {    this.name = "circle";    this.radius = radius;  }   area() {    return Math.PI * this.radius ** 2;  }   draw() {    return `● circle radius ${this.radius}`;  }} class Square {  constructor(size) {    this.name = "square";    this.size = size;  }   area() {    return this.size * this.size;  }   draw() {    return `■ square ${this.size}×${this.size}`;  }}  for (const shape of shapes) {  console.log(shape.draw() + " has area " + shape.area().toFixed(2));}
CallStoreChangeResultBlue line: runs next. Ran: already executed.
Recent returnsNothing yet. Start with the blue line.
Add a shape without changing the loop

Changing the array starts the replay again. The for...of loop stays the same.

A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.
The same call, shape.area().toFixed(2), goes to three different objects. Circle(3) answers 28.27, Square(4) answers 16.00, and Triangle(6, 5) answers 15.00.shape.area().toFixed(2)Circle(3)28.27Square(4)16.00Triangle(6, 5)15.00
The loop makes the same call on every shape. Each shape runs its own area(), so each gives a different answer.
Duck typing: use it with care

JavaScript code often uses duck typing. The name comes from a saying: "If it walks like a duck and quacks like a duck, treat it as a duck." In code it means: if an object has the method you need, use it. That's powerful, but it needs care:

  • Give methods clear names.
  • Keep each method small.
  • Test that every object really has the shared methods and returns the right kind of value.

The shapes loop works only if every shape keeps its side of the deal.

Factories vs classes

COMPARE

JavaScript has more than one way to make objects. These are the two most common:

  • A class is a blueprint. You make objects from it with new, like new Counter("class"). Each object made this way is an instance of the class.
  • A factory is a plain function that builds an object and returns it, like createCounter("factory").

A class is a good fit when:

  • you make many objects that share the same methods. The methods are stored once, on the class's prototype: a shared object where every instance looks for its methods.
  • you want instanceof to work. x instanceof Counter is true when x was made by new Counter().
  • you want #private fields.

A factory is a good fit when:

  • you want to hide data in a closure variable, like let count = 0.
  • you want to build a different object depending on the arguments.
  • you want to return a plain object that is easy to combine with others.

The playground below makes one counter each way. Press the button to run both.

A factory counter and a class counter
A factory counter and a class counterPop out in the code editor (opens in a new tab)JavaScript
function createCounter(label) {  let count = 0;  return {    next() {      count += 1;      return label + ": " + count;    },  };} class Counter {  #count = 0;  constructor(label) {    this.label = label;  }   next() {    this.#count += 1;    return this.label + ": " + this.#count;  }} const factoryCounter = createCounter("factory");const classCounter = new Counter("class");console.log(factoryCounter.next());console.log(classCounter.next());console.log(factoryCounter instanceof Counter);console.log(classCounter instanceof Counter);
Resultsnot run yet
factoryCounter.next()factory: 1
classCounter.next()class: 1
factoryCounter instanceof Counterfalse
classCounter instanceof Countertrue
Try it yourself

Both objects answer next() the same way. Only the class's object is an instance of Counter, so only it gives true for instanceof. The factory keeps count in a closure.

The code doesn't change. The results come from running the same code that the lesson's tests check.

Both counters answer next() the same way: each prints its label and 1. The difference is in the last two lines. The factory's object is not an instance of Counter, so instanceof gives false. The class's object is, so it gives true.

A plain function is enough herePop out in the code editor (opens in a new tab)JavaScript
function formatPrice(price) {
  return "$" + price.toFixed(2);
}

console.log(formatPrice(3));

Sometimes you need neither. formatPrice() turns a number into price text, like $3.00. It keeps no data between calls and has only one job. A plain function says exactly what it does. Wrapping it in a class would add extra code and give nothing useful back.

The table compares the two tools. Read each row as a question, then see how a factory and a class each answer it.

How a factory and a class compare
What you want to knowWith a factoryWith a class
How do you make an object?Call the function: createThing(options).Use new: new Thing(options).
Where do the methods live?Usually each object gets its own copies of the methods.On the prototype. All instances share one copy of each method.
How do you hide data?Keep it in a closure variable, like let count = 0.Use a #private field, like #count.
Do the methods use this?They don't have to. They can use closure variables instead.Usually yes. Methods read the object's data through this.
Does instanceof work?No. The object is a plain object, so instanceof Thing is false.Yes. instanceof Thing is true for objects made with new Thing().
How much memory do the methods use?More, because each object may get new method functions.Less, because all instances share the prototype's methods.

Neither column wins. Use a class when shared methods and instanceof make the code clearer. Use a factory when you want a small function, closure variables, or an object that changes with the input. Either way, other code should see only a small public interface.

SOLID in practice

REAL REFACTOR

SOLID started with people who design big programs out of classes. Its ideas also help in JavaScript, if you keep them light. Treat each rule as a question you ask about your code:

  • How many reasons does this piece of code have to change?
  • Can I add a new kind of thing without editing the loop?
  • Can I pass in a fake version of a tool when I test?

These questions help whether your objects come from classes, factories, or modules.

The table lists the five rules. Read each row as: the rule, the question it asks, and a small example.

The five SOLID rules, as JavaScript questions
RuleThe question to askA small example
Single ResponsibilityDoes this object have just one main reason to change?Split loading, adding up, and showing into separate pieces.
Open/ClosedCan I add a new kind of thing without editing a central list of if checks?Add a Triangle with its own area(). The loop stays the same.
Liskov SubstitutionCan a replacement object keep every promise of the one it replaces?A Square whose setWidth() also changes the height breaks this rule.
Interface SegregationCan the code ask for fewer methods?Ask only for area(), not for a big object with many methods.
Dependency InversionCan this object be given its tools, instead of building them in?Pass a storage object into the constructor.

Start with Single Responsibility. It means each piece of code should have one job. Imagine a class that does four jobs:

  • it loads an invoice from the server;
  • it adds up the total;
  • it formats the total as money text;
  • it writes HTML into the page.

That class has four reasons to change. Split it into smaller pieces: a loader, a calculator, a formatter, and a renderer (the piece that shows things on the page). Each piece can be an object, a function, or a module. Small pieces are easier to test and to replace.

Before: one method does three jobsPop out in the code editor (opens in a new tab)JavaScript
class InvoiceWidget {  async show(id) {    const invoice = await api.loadInvoice(id);    const total = invoice.items.reduce((sum, item) => sum + item.price, 0);    element.textContent = "Total: $" + total.toFixed(2);  }}

Line 3 loads the invoice; await waits for the server to answer. Line 4 adds up the prices. Line 5 writes the total into the page. That is three jobs, so the method has three reasons to change.

After: one class adds up, one class showsPop out in the code editor (opens in a new tab)JavaScript
class InvoiceFormatter {  total(invoice) {    return invoice.items.reduce((sum, item) => sum + item.price, 0);  }} class InvoiceRenderer {  constructor(element, formatter) {    this.element = element;    this.formatter = formatter;  }   show(invoice) {    const total = this.formatter.total(invoice);    this.element.textContent = "Total: $" + total.toFixed(2);  }}

InvoiceFormatter only adds up the total. InvoiceRenderer only writes to the page. It gets the page element and the formatter through its constructor, on lines 8 to 10, instead of making them itself. Loading the invoice can live in a third piece.

Dependency Inversion is the rule that helps testing most in front-end code. A dependency is a tool your code needs, like a place to save data. Don't build the tool into the class. Pass it in from outside instead.

In the example below, the panel doesn't use localStorage (the browser's built-in storage) directly. It gets a storage object through its constructor:

  • In the real app, you pass the browser's localStorage.
  • In a test, you pass a small fake that keeps the data in a Map.

The panel needs only two methods from it: setItem() and getItem(). Line 2 receives the storage, line 7 uses it, and line 22 passes in the fake.

Pass in a fake storage for a test
The panel is given its storagePop out in the code editor (opens in a new tab)JavaScript
class PreferencePanel {  constructor(storage) {    this.storage = storage;  }   chooseTheme(theme) {    this.storage.setItem("theme", theme);    return "Saved " + this.storage.getItem("theme");  }} const memoryStorage = new Map();const fakeStorage = {  setItem(key, value) {    memoryStorage.set(key, value);  },  getItem(key) {    return memoryStorage.get(key);  },}; const panel = new PreferencePanel(fakeStorage);console.log(panel.chooseTheme("dark"));
Fake storage resultdark
chooseTheme("dark")Saved dark
storage useda fake, in memory
Try it yourself

The panel uses whatever object it is given, as long as it has setItem() and getItem(). Here that is a fake storage built on a Map, so the result is "Saved dark".

Passing tools in keeps browser-only code out of the panel. Tests get the same result every time.
new PreferencePanel(fakeStorage). A fakeStorage card, built on a Map, is passed into the panel cart, where it becomes the panel's storage. Calling chooseTheme with dark prints Saved dark.new PreferencePanel(fakeStorage)fakeStoragebuilt on a Mappassed instoragepanelSaved darkwhat it prints
The panel doesn't make its own storage. It uses whatever object it is given, so this test passes a fake, and chooseTheme("dark") prints Saved dark.
Which principle is this?
  • A Thermostat keeps #targetCelsius private and checks every change.
  • A loop calls shape.area() on circles, squares, and triangles.
  • A panel is given a storage object instead of using localStorage directly.
  • A report class adds up totals and also writes HTML into the page.
  • A factory keeps count in a closure and returns a next() method.
  • A toolbar calls item.save() without checking if the item is a draft, a note, or an image.
Try it yourself
0 of 6 correct

Put each card in the group it shows best. Some cards fit more than one idea. Pick the strongest one.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

Liskov Substitution: the Square and Rectangle trap

STEP THROUGH

The Liskov Substitution rule is named after Barbara Liskov, the computer scientist who described it. It says: if code works with one kind of object, it should also work with a subtype of it, with no surprises. A subtype is a class that extends another class, like Square extends Rectangle.

The classic example looks harmless. A square sounds like a special kind of rectangle. But the two behave differently when you change their size:

  • A rectangle has a width and a height. You can change one without the other.
  • A square has one size. Its width and height must stay equal.

So if setWidth() on a square also changes the height, code written for rectangles gets a surprise. Step through the code below. makeWide() sets the width to 10 and returns the area. It expects the height to stay the same.

A Square used where a Rectangle is expected
Step 0 of 6Ready
Your turn: follow the blue line

Step through the Square and Rectangle trap. The fix is not a cleverer extends. It is a smaller shared promise, like just area().

Running in
  1. script
Next: line 32
class Rectangle {  constructor(width, height) {    this.width = width;    this.height = height;  }   setWidth(width) {    this.width = width;  }   area() {    return this.width * this.height;  }} class Square extends Rectangle {  constructor(size) {    super(size, size);  }   setWidth(width) {    this.width = width;    this.height = width;  }} function makeWide(shape) {  shape.setWidth(10);  return shape.area();} console.log(makeWide(new Square(4)));
CallStoreChangeResultBlue line: runs next. Ran: already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.
Left: a 4 by 5 rectangle. After setWidth(10) it is 10 by 5, so the area is 50. Right: a 4 by 4 square. After setWidth(10) it is 10 by 10, so the area is 100.new Rectangle(4, 5)4×5setWidth(10)10×5area 50new Square(4)4×4setWidth(10)10×10area 100
makeWide() calls setWidth(10) and expects the height to stay the same. The rectangle keeps its height and gives 50; the square changes its height too and gives 100.

The rectangle gives 50 (10 × 5). The square gives 100 (10 × 10), because its height changed too. Code written for rectangles did not expect that.

The fix is not a cleverer extends. Instead, make the shared promise smaller. If the code only needs area(), both shapes can offer just that, with no inheritance at all:

  • The rectangle keeps its setWidth() method.
  • The square keeps its own rule: one size for every side.

Not every "a square is a rectangle" sentence should become extends in code.

A safer design: both shapes just offer area()Pop out in the code editor (opens in a new tab)JavaScript
class ResizableRectangle {  constructor(width, height) {    this.width = width;    this.height = height;  }   setWidth(width) {    this.width = width;  }   area() {    return this.width * this.height;  }} class SquareShape {  constructor(size) {    this.size = size;  }   area() {    return this.size * this.size;  }} const shapes = [new ResizableRectangle(10, 5), new SquareShape(4)];console.log(shapes.map((shape) => shape.area()).join(", "));

Line 26 puts both shapes in one array. Line 27 calls area() on each one and prints 50, 16. Nothing changes a size it doesn't own.

A Receipt is given a sender to usePop out in the code editor (opens in a new tab)JavaScript
class Receipt {
  constructor(sender) {
    this.sender = sender;
  }

  send(order) {
    return this.sender.send("Receipt for " + order);
  }
}

const email = {
  send(message) {
    return "Email: " + message;
  },
};

console.log(new Receipt(email).send("Asha"));

Composition means building an object out of other objects it is given, instead of inheriting from them. Receipt does not extend an email class. It gets a sender object in its constructor and calls this.sender.send(). Here the sender is email, so the last line prints Email: Receipt for Asha.

To send a text message instead, pass a different object that has a send() method. Receipt doesn't change.

Where you will use this

These ideas show up whenever a front-end program grows past one small script. For example:

  • A form field object keeps its own checks and offers isValid().
  • Several payment methods all offer charge(). The checkout code doesn't need to know if the user picked a card, a gift balance, or a saved wallet.
  • A table component is given a sorting function, instead of having one sort order built in.
  • A test passes a fake storage, a fake clock, or a fake analytics tracker into a class that normally talks to the browser.

You will also learn to notice when OOP is too much:

  • If a module holds one helper function, it doesn't need a class just to look official.
  • If two objects share only a small bit of behavior, a mixin, a plain function, or delegation can be cleaner than inheritance.

For choosing between bigger patterns, Creational patterns and Structuring an application come in Stage 9. For now, pick the smallest public interface that makes change easier.

A simple rule of thumb

Before you add a class, write down the public methods you want other code to call. If the list is short and doesn't keep changing, a class or a factory can help. If the list keeps growing, split the jobs into smaller pieces first.

Common misconceptions

"OOP in JavaScript means copying the style of Java or C#."

JavaScript has its own tools: prototypes, functions you can pass around like values, closures, modules, and classes. Use the OOP ideas, not another language's extra code. A factory that hides data in a closure is just as much OOP as a class, as long as it returns an object with clear methods.

"Private fields make code secure."

A #private field stops normal JavaScript code outside the class from reading or changing it. That protects your design from mistakes. It does not keep secrets. Anyone with the source code, a browser extension, or the browser's developer tools can still see the data. Use private fields to keep your design honest, not to hide passwords or keys.

"Polymorphism needs inheritance."

It doesn't. The shapes loop only needs objects that have area() and draw(). They could come from classes that have nothing to do with each other, from factories, from object literals, or from mixins.

"SOLID means every tiny thing needs its own interface."

Adding layers too early makes code harder to read, not easier. Reach for SOLID when changing the code starts to hurt. Signs of that:

  • one class with too many jobs;
  • the same if checks repeated in many places;
  • browser tools like localStorage used directly everywhere;
  • tests that need the whole app to run.

"If a square is a kind of rectangle, Square should extend Rectangle."

The Square and Rectangle example shows the trap. What an object does matters more than what it is called. If a subclass can't keep every promise of the class it extends, use composition or a smaller shared interface instead.

Practice: design with objects

5 EXERCISES
Exercise 1 · Warm-upAdd a shape without changing the loop

Read the Hexagon class. It has the same two methods as the other shapes, so the loop can use it. Run the code and type the exact text it prints.

Starter codePop out in the code editor (opens in a new tab)JavaScript
class Hexagon {
  constructor(side) {
    this.side = side;
  }
  area() {
    return ((3 * Math.sqrt(3)) / 2) * this.side ** 2;
  }
  draw() {
    return "⬡ hexagon side " + this.side;
  }
}

const shapes = [new Hexagon(2)];
for (const shape of shapes) {
  console.log(shape.draw() + " area " + shape.area().toFixed(2));
}

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · PracticeSplit a class that does too much

    Split InvoiceWidget into at least two smaller classes. Then compare your answer with the worked solution. This exercise is about design, so there is no answer box.

    Starter codePop out in the code editor (opens in a new tab)JavaScript
    class InvoiceWidget {
      async show(id) {
        const invoice = await api.loadInvoice(id);
        const total = invoice.items.reduce((sum, item) => sum + item.price, 0);
        element.textContent = "Total: $" + total.toFixed(2);
      }
    }
      Exercise 3 · PracticePass in a fake storage for a test

      Run the code. What does the test with the fake storage print?

      Starter codePop out in the code editor (opens in a new tab)JavaScript
      class PreferencePanel {
        constructor(storage) {
          this.storage = storage;
        }
        chooseTheme(theme) {
          this.storage.setItem("theme", theme);
          return this.storage.getItem("theme");
        }
      }
      
      const memory = new Map();
      const fakeStorage = {
        setItem(key, value) { memory.set(key, value); },
        getItem(key) { return memory.get(key); },
      };
      
      const panel = new PreferencePanel(fakeStorage);
      console.log(panel.chooseTheme("dark"));

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · ChallengeFind where Square breaks the Liskov rule

        makeWide() was written for rectangles. What number does it print for a Square? That number shows the surprise.

        Starter codePop out in the code editor (opens in a new tab)JavaScript
        class Rectangle {
          constructor(width, height) { this.width = width; this.height = height; }
          setWidth(width) { this.width = width; }
          area() { return this.width * this.height; }
        }
        class Square extends Rectangle {
          constructor(size) { super(size, size); }
          setWidth(width) { this.width = width; this.height = width; }
        }
        function makeWide(shape) {
          shape.setWidth(10);
          return shape.area();
        }
        console.log(makeWide(new Square(4)));

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeChoose a factory or a class

          You need thousands of objects with the same methods. You want them to share one copy of each method, not get new copies each time. Which tool would you try first? Type factory or class.

          Answer, then press Check. Spacing and letter case don’t matter.

            Quiz: check your understanding

            7 QUESTIONS

            Read the explanations even when you get it right. The wrong choices are common design mistakes.

            Lesson quiz · 7 questionsScore: first tries count
            1. Question 1 of 7Which pair best describes encapsulation and abstraction?

              Choose an answer to see the explanation.

            2. Question 2 of 7What does this shape code print?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              class Circle {
                area() { return 12; }
              }
              class Square {
                area() { return 9; }
              }
              const shapes = [new Circle(), new Square()];
              console.log(shapes.map((shape) => shape.area()).join(","));

              Choose an answer to see the explanation.

            3. Question 3 of 7When is a factory a good fit?

              Choose an answer to see the explanation.

            4. Question 4 of 7What does this thermostat code print?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              class Thermostat {
                #target = 20;
                setTarget(value) {
                  if (value > 35) return "refused";
                  this.#target = value;
                  return "saved";
                }
                get display() { return this.#target; }
              }
              const t = new Thermostat();
              console.log(t.setTarget(41));
              console.log(t.display);

              Choose an answer to see the explanation.

            5. Question 5 of 7Which change follows the Open/Closed rule?

              Choose an answer to see the explanation.

            6. Question 6 of 7What does this counter code print?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              function createCounter() {
                let count = 0;
                return { next() { count += 1; return count; } };
              }
              const a = createCounter();
              const b = createCounter();
              console.log(a.next(), a.next(), b.next());

              Choose an answer to see the explanation.

            7. Question 7 of 7Which design is easiest to test?

              Choose an answer to see the explanation.

            Key takeaways

            • Encapsulation protects an object's data. Every change goes through public methods, which can check the rules first.
            • Abstraction gives other code a simple idea to use, like setTarget(22), and hides the steps behind it.
            • Polymorphism lets one method call work on different objects. That is how you add new kinds of things without editing old code (Open/Closed).
            • Factories and classes both work. Choose by what you need: closure variables, shared prototype methods, this, instanceof, and clear code.
            • Use SOLID as five questions when you change code: one job per piece, no growing lists of if checks, replacements that keep their promises, small interfaces, and tools passed in from outside.

            Remember the one-liner.
            OOP in JavaScript means giving each object a small list of methods, hiding the parts that break easily, and letting other code ask for what it wants instead of reaching inside.

            Up next: Property flags & descriptors. You'll look at a single object property up close, and learn the settings that control whether it can be changed, listed, or deleted.

            CompleteFrontend Clear concepts. Working examples.