cf.completefrontendCode editorOpen lab
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
    Design public interfacesHide fragile state with private fields or closures and expose small methods that validate changes.
  • 02
    Use polymorphism without ceremoniesWrite loops and collaborators that ask for behavior, not a specific class name.
  • 03
    Choose classes, factories, and SOLID tradeoffsCompare JavaScript-flavored options and refactor code toward testing and change.

Objects with promises

Object-oriented programming, or OOP, is not a requirement to write JavaScript. You can build excellent programs with functions, modules, closures, plain objects, prototypes, classes, or a mix of all of them. The useful part of OOP is smaller than the drama around it: an object can make a promise about what messages it understands.

A Thermostat promises setTarget() and display. A Circle, Square, and Triangle can all promise area() and draw(). Code that depends on those promises can ignore the messy details: private fields, formulas, storage choices, and constructor arguments stay behind the object’s public surface.

The JavaScript-flavored definition

OOP in JavaScript means designing objects around small public interfaces, hiding the state that should not be changed directly, and letting different objects answer the same method call in their own way. Classes are one tool for that. Factories, closures, delegation, and mixins are tools too.

This lesson is the capstone for the Classes module. You have already seen class syntax, extends and super, static members, #private fields, extending built-ins, and mixins. Earlier foundations such as Closure patterns and Delegation vs classical inheritance explain why JavaScript does not need to copy another language’s class culture. Stage 9 will go further with Creational patterns and Structuring an application; today we keep the principles practical and close to code you can run.

Real-life analogyOOP principles are kitchen rules

A busy kitchen turns chaotic when everyone touches every station, one tool tries to do every job, or a replacement pan does something surprising. SOLID is a set of five kitchen rules for code: not a religion, not a checklist to maximize, just reminders that reduce collisions when a program grows.

In real life: One cook owns the sauce station
In JavaScript: Single Responsibility: one reason to change
In real life: Add a new dish without rewriting every ticket
In JavaScript: Open/Closed: extend behavior through a shared interface
In real life: A substitute pan must still work on the stove
In JavaScript: Liskov: replacements keep the same promise
In real life: Use small tools instead of one giant gadget
In JavaScript: Interface Segregation: depend on the methods you actually use
In real life: Plug in any mixer that fits the outlet
In JavaScript: Dependency Inversion: receive capabilities instead of hard-coding them

Where the analogy stops: Kitchen rules do not cook dinner by themselves. SOLID principles do not design your app for you; they are warning lights for code that is hard to change.

Encapsulation & abstraction

STEP THROUGH

Encapsulation means putting data and the operations that protect it together. The object owns its invariants: rules that should always stay true. In JavaScript you can encapsulate with module scope, closure variables, WeakMaps, symbols, ordinary naming conventions, or the strongest class feature: #private fields and methods. The important part is not the punctuation. The important part is that outside code has fewer ways to create an impossible state.

Abstraction means showing the caller a smaller idea than the implementation. Callers should say thermostat.setTarget(22), not “check a range, choose the Celsius storage slot, update it, then format text for the screen.” The public method is the concept; the private field is the machinery.

Real-life analogyEncapsulation is a car dashboard

You drive by using the steering wheel, pedals, and dashboard. You do not reach into the engine while merging onto a highway. A good class feels the same: it exposes controls for normal use and keeps fragile internals behind a panel.

In real life: Steering wheel and pedals
In JavaScript: Public methods and getters
In real life: Engine, belts, and fuel system
In JavaScript: Private state and implementation details
In real life: Warning lights and speedometer
In JavaScript: Validation and read-only display

Where the analogy stops: A mechanic sometimes opens the hood. In code, tests and debugging tools can inspect more deeply, but normal callers should use the public interface.

Real-life analogyAbstraction is a restaurant menu

A restaurant menu gives you useful names: pasta, soup, salad. It does not hand you a recipe card for boiling water. Abstraction does the same for code: it gives callers the intention, not the whole procedure.

In real life: Order “pasta”
In JavaScript: Call order.pasta() or thermostat.setTarget(22)
In real life: Kitchen boils water, adds salt, times sauce
In JavaScript: Implementation details hidden behind the method
In real life: The menu limits choices
In JavaScript: The public interface guides safe use

Where the analogy stops: A real menu may omit allergy details you need. In code, abstractions should still reveal enough information for correct use.

Try the thermostat. Request a safe value, then request values outside the allowed range. The private field refuses to move into an invalid state because the only public path runs validation first.

Encapsulation: a thermostat guards private state
Step 0 of 5Ready
Your turn: follow the blue line

Choose a target, then step through. Encapsulation is not secrecy for its own sake; it keeps invalid state out.

Running in
  1. script
Next: line 18
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
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);
CallStoreChangeResultRun = next line. Ran = already executed.
Recent returnsNothing yet. Start with the blue line.
Choose the requested target temperature

Only 10–35°C is accepted. Invalid values prove the field stayed protected.

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.
Why not just trust callers?

You can trust today’s caller and still protect tomorrow’s code. Encapsulation creates one place for a rule, so a future button, import script, or test helper cannot accidentally skip it. It also makes refactoring safer: if callers use display and setTarget(), the class can change its internal storage later without changing every caller.

Polymorphism: one message, many answers

INTERACTIVE

Polymorphism means “many forms.” In JavaScript it is usually simple: if several objects have the same method name, a caller can use them through that shared behavior. The caller does not need a formal interface declaration. It simply calls the method and lets the receiving object decide what happens.

Real-life analogyPolymorphism is a universal remote

A universal remote has one power button. Press it near a TV, a speaker, or a light, and each device handles “power” its own way. Polymorphic JavaScript works like that: one method name, different implementations behind it.

In real life: One power button
In JavaScript: One call: device.power() or shape.area()
In real life: TV, speaker, and lights respond differently
In JavaScript: Circle, Square, and Triangle run different methods
In real life: The remote does not open each device
In JavaScript: The caller avoids class-specific branches

Where the analogy stops: A real remote needs setup codes. JavaScript checks at runtime: if an object lacks the method, the call fails. Good tests and clear naming are your setup codes.

The shapes playground is also an Open/Closed example. The loop is closed to modification: it always calls draw() and area(). The program is open to extension: add Triangle with the same methods, and the loop keeps working. That is more JavaScript-flavored than writing a chain of if (shape.name === ...) checks.

Polymorphism: one loop, many shapes
Step 0 of 5Ready
Your turn: follow the blue line

Predict each printed line. Then add Triangle with the controls and notice that the loop stays exactly the same.

Running in
  1. script
Next: line 31
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
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));}
CallStoreChangeResultRun = next line. Ran = already executed.
Recent returnsNothing yet. Start with the blue line.
Add a shape without editing the loop

Changing the array starts a fresh replay. The for...of loop does not change.

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.
Duck typing, carefully

JavaScript often uses “duck typing”: if it walks like a duck and quacks like a duck, use it as a duck. That is powerful, but not an excuse to be vague. Name methods clearly, keep them small, and test the shared contract. A polymorphic loop is only as good as the promise each object keeps.

Factories vs classes

COMPARE

JavaScript gives you more than one way to make objects. A class is great when many instances share methods on a prototype, you want instanceof to communicate identity, or you want #private fields with class syntax. A factory is just a function that returns an object. It shines when setup is conditional, when you want closure privacy, or when returning a plain object makes composition easier.

Factories and classes can expose the same behavior
Factory and class countersPop 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);
Runtime resultsinitial
factory.next()factory: 1
class.next()class: 1
factory instanceof Counterfalse
class instanceof Countertrue
Try it yourself

Both objects answer next(). The class instance also carries a prototype identity for instanceof; the factory keeps count in a closure.

The code is fixed, and the displayed values come from the real helper used by the tests.
Choosing a JavaScript object-making tool
QuestionFactoryClass
How do you call it?createThing(options)new Thing(options)
Where do shared methods live?Usually each returned object, unless you share helpers manuallyOn the prototype, so many instances share one method function
Privacy optionClosure variables such as let count = 0#private fields and methods
this behaviorCan avoid this, or use inline methodsMethods usually read state through this
Identity checksUsually no useful instanceofinstanceof Thing can be meaningful
Memory patternSimple but may create new method functions per objectPrototype methods are shared across instances

The table is not a scoreboard. Use a class when shared prototype behavior and identity make the code clearer. Use a factory when you want a small creator function, closure state, or a return value that varies by input. In both cases, the caller should see a small public interface, not a pile of internals.

SOLID in practice

REAL REFACTOR

SOLID came from class-heavy design discussions, but the helpful parts translate well to JavaScript when you keep them lightweight. Treat the names as questions, not commandments. “How many reasons does this module have to change?” “Can I add a new kind without editing the loop?” “Can I pass a fake dependency in a test?” Those questions are useful whether your objects come from classes, factories, or modules.

The SOLID kitchen rules, JavaScript edition
PrinciplePractical JavaScript questionTiny example
Single ResponsibilityDoes this object have one main reason to change?Split fetch, format, and render pieces.
Open/ClosedCan I add a new kind without editing a central branch?Add Triangle.area() instead of changing the loop.
Liskov SubstitutionCan a replacement keep the same promise?A Square that changes height in setWidth() fails.
Interface SegregationCan callers depend on a smaller capability?Require area(), not a giant shape object.
Dependency InversionCan I receive this dependency instead of hard-coding it?Pass storage into the constructor.

Start with Single Responsibility. A class that fetches an invoice, calculates totals, formats currency, and writes HTML has four reasons to change. Split it into collaborators: one loader, one calculator, one formatter, one renderer. The code may use objects, functions, or modules; the win is that each piece is easier to test and replace.

Before: one object with too many 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);  }}
After: fetch, format, and render can change separatelyPop 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);  }}

Dependency Inversion is the most test-friendly rule for front-end JavaScript. Instead of hard-coding localStorage, pass in a storage-like object. Production code can pass the browser’s storage; tests can pass a tiny in-memory fake.

Dependency inversion: test with fake storage
A storage dependency passed inPop 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 outputdark
chooseTheme(dark)Saved dark
storage kindin-memory fake
Try it yourself

The panel talks to whatever object has setItem/getItem. Here the dependency is a fake Map-backed storage, so the result is Saved dark.

Passing dependencies in keeps browser-specific APIs at the edge and makes tests deterministic.
Which principle is this?
  • A Thermostat keeps #targetCelsius private and validates changes.
  • A loop calls shape.area() on circles, squares, and triangles.
  • A panel receives a storage object instead of importing localStorage directly.
  • A report class formats totals and also writes HTML into the page.
  • A factory stores count in a closure and returns a next() method.
  • A toolbar calls item.save() without caring whether the item is a draft, note, or image.
Try it yourself
0 of 6 correct

Sort each design move by the principle it mostly demonstrates. Some examples touch more than one idea; choose the strongest signal.

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

Liskov substitution: the Square/Rectangle trap

STEP THROUGH

Liskov Substitution says a subtype should be usable anywhere its base type is expected without surprising the caller. The classic example is tempting: a square sounds like a special rectangle. But a mutable rectangle has two independent dimensions, while a square has one size. If setWidth() on a square secretly changes height too, code written for rectangles can get a shocking result.

Liskov: when Square is not a safe Rectangle
Step 0 of 6Ready
Your turn: follow the blue line

Step through the classic Square/Rectangle trap. The fix is not clever inheritance; it is a smaller shared interface such as area().

Running in
  1. script
Next: line 32
Click the blue line to take the next stepPop 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)));
CallStoreChangeResultRun = next line. 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.

The fix is not to force the inheritance tree harder. Make the shared contract smaller. If a caller only needs area(), both objects can implement a shape interface by convention. The resizable rectangle keeps width-changing behavior; the square keeps its own invariant. Composition beats pretending every “is a” sentence should become extends.

A safer design: share only the area contractPop 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(", "));

Where you will use this

These principles show up anywhere a front-end program grows beyond a single script. A form field object might encapsulate validation and expose isValid(). A group of payment methods might share charge() so checkout code does not know whether the user picked a card, gift balance, or saved wallet. A table component might receive a sorting strategy instead of hard-coding one. A test might pass fake storage, fake time, or a fake analytics reporter into a class that normally talks to browser APIs.

You will also use the negative version: noticing when OOP is too much. If a module holds one helper function, it does not need a class just to look official. If two objects only share a small behavior, a mixin, plain function, or delegation can be cleaner than inheritance. If you are choosing among patterns, remember that Creational patterns and Structuring an application arrive in Stage 9; for now, prefer the smallest interface that makes change easier.

A practical rule of thumb

Before adding a class, write down the public methods you want callers to use. If that list is short and stable, a class or factory can help. If the list keeps growing, split responsibilities before you design the inheritance tree.

Common misconceptions

“OOP in JavaScript means copying Java or C# style.”

JavaScript has prototypes, first-class functions, closures, modules, and classes. Use the principles, not another language’s ceremony. A factory with closure privacy can be just as object-oriented as a class when it exposes a clear object.

“Private fields make code secure.”

#private protects object invariants from normal JavaScript access. It is not a security boundary against your own server, browser extensions, or someone with source code. Use it to keep designs honest, not to hide secrets.

“Polymorphism requires inheritance.”

It does not. The shapes loop only needs objects with area() and draw(). They could come from unrelated classes, factories, object literals, or mixins.

“SOLID means every tiny thing needs an interface.”

In JavaScript, over-abstracting too early is its own mess. Reach for SOLID when change hurts: big classes, repeated branches, hard-coded browser APIs, or tests that require the whole app.

“Subclassing always proves an is-a relationship.”

The Square/Rectangle example shows the trap. Behavior matters more than vocabulary. If the subtype cannot keep the base class’s promise, prefer composition or a smaller shared interface.

Practice: design with objects

5 EXERCISES
Exercise 1 · Warm-upAdd a shape polymorphically

Add a new shape object that works with the existing loop. Run the code and enter the exact console output.

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 · PracticeRefactor a god-class into two pieces

    Split the InvoiceWidget idea into at least two collaborators. Compare your answer to the worked solution; this one is about design, so there is no text 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 · PracticeInject a dependency for testing

      Run the dependency-inversion snippet. What does the fake storage test 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 · ChallengeSpot the Liskov violation

        What number proves that using Square as a mutable Rectangle surprised the caller?

        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 factory or class

          You are creating thousands of objects with the same methods and you want those methods shared rather than recreated per object. Type the tool you would reach for first: 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 are right. The wrong answers are common design traps.

            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 the polymorphic 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 the thermostat guard 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 is Open/Closed in action?

              Choose an answer to see the explanation.

            6. Question 6 of 7What does the factory 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 state by forcing changes through a public interface that can validate rules.
            • Abstraction gives callers a smaller idea to use: a dashboard or menu, not the engine or recipe.
            • Polymorphism lets one call work across different objects, which is how Open/Closed stays practical in JavaScript.
            • Factories and classes are both valid. Choose based on closure privacy, prototype sharing, this, instanceof, and clarity.
            • SOLID is most useful as a set of refactoring questions: split reasons to change, avoid central branches, keep substitutions honest, depend on small capabilities, and inject dependencies.

            Remember the one-liner.
            OOP in JavaScript is designing small object promises that hide fragile details and let callers ask for behavior, not internals.

            Up next: Property flags & descriptors, where you look under every object property and learn the flags that control assignment, enumeration, and configuration.

            CompleteFrontend Clear concepts. Working examples.