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.
- 01Design public interfacesHide fragile state with private fields or closures and expose small methods that validate changes.
- 02Use polymorphism without ceremoniesWrite loops and collaborators that ask for behavior, not a specific class name.
- 03Choose 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.
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.
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 THROUGHEncapsulation 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.
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.
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()orthermostat.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.
Choose a target, then step through. Encapsulation is not secrecy for its own sake; it keeps invalid state out.
script
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);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
INTERACTIVEPolymorphism 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.
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()orshape.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.
Predict each printed line. Then add Triangle with the controls and notice that the loop stays exactly the same.
script
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));}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
COMPAREJavaScript 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.
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);Both objects answer next(). The class instance also carries a prototype identity for instanceof; the factory keeps count in a closure.
| Question | Factory | Class |
|---|---|---|
| How do you call it? | createThing(options) | new Thing(options) |
| Where do shared methods live? | Usually each returned object, unless you share helpers manually | On the prototype, so many instances share one method function |
| Privacy option | Closure variables such as let count = 0 | #private fields and methods |
this behavior | Can avoid this, or use inline methods | Methods usually read state through this |
| Identity checks | Usually no useful instanceof | instanceof Thing can be meaningful |
| Memory pattern | Simple but may create new method functions per object | Prototype 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 REFACTORSOLID 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.
| Principle | Practical JavaScript question | Tiny example |
|---|---|---|
| Single Responsibility | Does this object have one main reason to change? | Split fetch, format, and render pieces. |
| Open/Closed | Can I add a new kind without editing a central branch? | Add Triangle.area() instead of changing the loop. |
| Liskov Substitution | Can a replacement keep the same promise? | A Square that changes height in setWidth() fails. |
| Interface Segregation | Can callers depend on a smaller capability? | Require area(), not a giant shape object. |
| Dependency Inversion | Can 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.
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); }}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.
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"));The panel talks to whatever object has setItem/getItem. Here the dependency is a fake Map-backed storage, so the result is Saved dark.
- A
Thermostatkeeps#targetCelsiusprivate and validates changes. - A loop calls
shape.area()on circles, squares, and triangles. - A panel receives a
storageobject instead of importinglocalStoragedirectly. - A report class formats totals and also writes HTML into the page.
- A factory stores
countin a closure and returns anext()method. - A toolbar calls
item.save()without caring whether the item is a draft, note, or image.
Sort each design move by the principle it mostly demonstrates. Some examples touch more than one idea; choose the strongest signal.
Liskov substitution: the Square/Rectangle trap
STEP THROUGHLiskov 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.
Step through the classic Square/Rectangle trap. The fix is not clever inheritance; it is a smaller shared interface such as area().
script
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)));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.
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.
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 EXERCISESAdd a new shape object that works with the existing loop. Run the code and enter the exact console output.
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));
}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));
}The loop only knows draw() and area(). Hexagon keeps that promise, so the loop prints ⬡ hexagon side 2 area 10.39 without a new branch.
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.
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);
}
}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) {
this.element.textContent = "Total: $" + this.formatter.total(invoice).toFixed(2);
}
}The formatter has math rules. The renderer has page-writing rules. Fetching can live in a separate loader. Each piece now has a clearer reason to change.
Run the dependency-inversion snippet. What does the fake storage test print?
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"));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"));The panel does not know or care whether storage is localStorage or a fake object. That lets the test print dark without touching a browser API.
What number proves that using Square as a mutable Rectangle surprised the caller?
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)));The output is 100. makeWide expects width to change independently, but Square.setWidth() also changes height. The subtype did not keep the base class promise.
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.
For many instances with the same methods, start with class: prototype methods are shared, and instanceof can communicate identity. A factory is still great when closure privacy or conditional creation matters more.
Quiz: check your understanding
7 QUESTIONSRead the explanations even when you are right. The wrong answers are common design traps.
Question 1 of 7Which pair best describes encapsulation and abstraction?
Choose an answer to see the explanation.
Question 2 of 7What does the polymorphic shape code print?
Read the code, then predictclass 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.
Question 3 of 7When is a factory a good fit?
Choose an answer to see the explanation.
Question 4 of 7What does the thermostat guard code print?
Read the code, then predictclass 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.
Question 5 of 7Which change is Open/Closed in action?
Choose an answer to see the explanation.
Question 6 of 7What does the factory counter code print?
Read the code, then predictfunction 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.
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.