Object-Oriented Programming is a paradigm that organizes code around objects — data structures that bundle state (properties) and behavior (methods) together. While JavaScript is not a classical OOP language like Java or C++, it has a powerful and flexible object system built on prototypes that supports every OOP principle you need.
Understanding how OOP works in JavaScript is essential for building scalable applications, designing clean APIs, and working effectively with modern frameworks like React, Angular, or Node.js.
In this guide, we will explore OOP from the ground up — starting with the fundamentals and building toward the patterns used in production codebases.
The Four Pillars of OOP
Before diving into JavaScript's implementation, let's establish the four core principles that define Object-Oriented Programming:
- Encapsulation: Bundling data and the methods that operate on that data inside a single unit (object), while restricting direct access to some of the object's internals.
- Abstraction: Hiding complex implementation details behind a simple interface. The consumer of an object doesn't need to know how it works — only what it does.
- Inheritance: A mechanism where one object can derive properties and methods from another, enabling code reuse and establishing a hierarchy.
- Polymorphism: The ability for different objects to respond to the same method call in different ways, depending on their type.
JavaScript supports all four, but it does so in its own unique way — through prototypal inheritance rather than classical inheritance.
Objects: The Foundation
In JavaScript, almost everything is an object. Arrays, functions, dates, regular expressions — they all inherit from Object.prototype. At its core, an object is a collection of key-value pairs.
// Object literal — the simplest way to create an object
const user = {
name: 'Jeffrin',
role: 'Full Stack Developer',
greet() {
return `Hi, I'm ${this.name}`;
}
};
console.log(user.greet()); // "Hi, I'm Jeffrin"
Object literals are great for one-off objects, but they fall short when you need to create multiple objects with the same structure. This is where constructor functions and classes come in.
Constructor Functions and new
Before ES6 classes existed, JavaScript developers used constructor functions to create reusable object blueprints. This pattern is still foundational to understanding how JavaScript's OOP model works.
function User(name, role) {
this.name = name;
this.role = role;
}
User.prototype.greet = function () {
return `Hi, I'm ${this.name}, a ${this.role}`;
};
const jeff = new User('Jeffrin', 'Full Stack Developer');
console.log(jeff.greet()); // "Hi, I'm Jeffrin, a Full Stack Developer"
When you call a function with the new keyword, four things happen behind the scenes:
- A brand new empty object is created:
{}. - The object's internal
[[Prototype]]is linked to the constructor'sprototypeproperty. - The constructor function is invoked with
thisbound to the new object. - If the function doesn't return an explicit object, the new object is returned automatically.
// What `new User(...)` does internally (simplified)
function fakeNew(Constructor, ...args) {
const obj = Object.create(Constructor.prototype); // Step 1 & 2
const result = Constructor.apply(obj, args); // Step 3
return result instanceof Object ? result : obj; // Step 4
}
The Prototype Chain
JavaScript's inheritance model is built on the prototype chain. Every object has a hidden internal property called [[Prototype]] (accessible via __proto__ or Object.getPrototypeOf()) that points to another object — its prototype.
When you access a property on an object, the engine first checks the object itself. If the property isn't found, it walks up the prototype chain — checking each prototype until it either finds the property or reaches null.
function Animal(name) {
this.name = name;
}
Animal.prototype.speak = function () {
return `${this.name} makes a sound`;
};
function Dog(name, breed) {
Animal.call(this, name); // Invoke parent constructor
this.breed = breed;
}
// Set up the prototype chain
Dog.prototype = Object.create(Animal.prototype);
Dog.prototype.constructor = Dog;
Dog.prototype.bark = function () {
return `${this.name} barks!`;
};
const rex = new Dog('Rex', 'Shepherd');
console.log(rex.bark()); // "Rex barks!" ← found on Dog.prototype
console.log(rex.speak()); // "Rex makes a sound" ← found on Animal.prototype
console.log(rex.toString()); // "[object Object]" ← found on Object.prototype
The lookup chain for rex.speak() is:
- Check
rexitself → not found. - Check
Dog.prototype→ not found. - Check
Animal.prototype→ found. Execute it.
If it had reached Object.prototype and still not found the property, it would return undefined.
ES6 Classes: Syntactic Sugar
ES6 introduced the class syntax, which provides a cleaner, more familiar way to write constructor functions and prototypes. Under the hood, classes are just functions — the prototype chain works exactly the same way.
class User {
constructor(name, role) {
this.name = name;
this.role = role;
}
greet() {
return `Hi, I'm ${this.name}`;
}
get info() {
return `${this.name} — ${this.role}`;
}
}
const jeff = new User('Jeffrin', 'Full Stack Developer');
console.log(jeff.greet()); // "Hi, I'm Jeffrin"
console.log(jeff.info); // "Jeffrin — Full Stack Developer"
Key differences from constructor functions:
- Classes are not hoisted (unlike function declarations). You cannot use them before their definition.
- Class methods are non-enumerable by default.
- Calling a class without
newthrows aTypeError.
Inheritance with extends and super
The extends keyword sets up the prototype chain between two classes, while super calls the parent class's constructor or methods.
class Shape {
constructor(color) {
this.color = color;
}
describe() {
return `A ${this.color} shape`;
}
}
class Circle extends Shape {
constructor(color, radius) {
super(color); // MUST call super() before using `this`
this.radius = radius;
}
area() {
return Math.PI * this.radius ** 2;
}
// Override parent method
describe() {
return `A ${this.color} circle with radius ${this.radius}`;
}
}
class Rectangle extends Shape {
constructor(color, width, height) {
super(color);
this.width = width;
this.height = height;
}
area() {
return this.width * this.height;
}
describe() {
return `A ${this.color} rectangle (${this.width}×${this.height})`;
}
}
const c = new Circle('blue', 5);
const r = new Rectangle('red', 4, 6);
console.log(c.describe()); // "A blue circle with radius 5"
console.log(c.area()); // 78.539...
console.log(r.describe()); // "A red rectangle (4×6)"
console.log(r.area()); // 24
Notice how both Circle and Rectangle override the describe() method from Shape — this is polymorphism in action.
Encapsulation: Private Fields and Methods
True encapsulation requires restricting access to an object's internals. JavaScript now supports private class fields using the # prefix (introduced in ES2022).
class BankAccount {
#balance; // Private field — inaccessible outside the class
#owner;
constructor(owner, initialBalance) {
this.#owner = owner;
this.#balance = initialBalance;
}
deposit(amount) {
if (amount <= 0) throw new Error('Deposit must be positive');
this.#balance += amount;
return this;
}
withdraw(amount) {
if (amount > this.#balance) throw new Error('Insufficient funds');
this.#balance -= amount;
return this;
}
get statement() {
return `${this.#owner}: $${this.#balance.toFixed(2)}`;
}
}
const account = new BankAccount('Jeffrin', 1000);
account.deposit(500).withdraw(200);
console.log(account.statement); // "Jeffrin: $1300.00"
console.log(account.#balance); // SyntaxError: Private field '#balance'
The #balance field is truly private — it cannot be accessed or modified from outside the class, not even by subclasses. This prevents external code from putting the object in an invalid state.
Before the # syntax, developers used conventions like underscore prefixes (_balance) or closures via WeakMap to simulate privacy — but those were workarounds, not true encapsulation.
Static Methods and Properties
Static members belong to the class itself, not to instances. They're useful for utility functions, factory methods, and shared configuration.
class MathUtils {
static PI = 3.14159265358979;
static circleArea(radius) {
return MathUtils.PI * radius ** 2;
}
static clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
}
// Called on the class, not on an instance
console.log(MathUtils.circleArea(5)); // 78.539...
console.log(MathUtils.clamp(15, 0, 10)); // 10
A common real-world pattern is a static factory method — a static method that creates instances in a controlled way:
class User {
constructor(name, email, role) {
this.name = name;
this.email = email;
this.role = role;
}
static fromJSON(json) {
const data = JSON.parse(json);
return new User(data.name, data.email, data.role);
}
static guest() {
return new User('Guest', '', 'viewer');
}
}
const user = User.fromJSON('{"name":"Jeff","email":"[email protected]","role":"admin"}');
const guest = User.guest();
Abstraction in Practice
JavaScript doesn't have a built-in abstract keyword like Java or TypeScript, but you can enforce abstraction by throwing errors in base class methods that subclasses must override.
class Component {
constructor(id) {
if (new.target === Component) {
throw new Error('Component is abstract — cannot instantiate directly');
}
this.id = id;
}
// Abstract method — subclasses MUST implement this
render() {
throw new Error(`${this.constructor.name} must implement render()`);
}
mount(container) {
const html = this.render();
container.innerHTML = html;
console.log(`${this.constructor.name} mounted to #${container.id}`);
}
}
class Button extends Component {
constructor(id, label) {
super(id);
this.label = label;
}
render() {
return `<button id="${this.id}">${this.label}</button>`;
}
}
class Card extends Component {
constructor(id, title, body) {
super(id);
this.title = title;
this.body = body;
}
render() {
return `<div id="${this.id}"><h3>${this.title}</h3><p>${this.body}</p></div>`;
}
}
// new Component('test'); // Error: Component is abstract
const btn = new Button('submit-btn', 'Submit');
console.log(btn.render()); // "<button id="submit-btn">Submit</button>"
The new.target check prevents direct instantiation of the abstract class, while the base render() throws if a subclass forgets to implement it.
Composition Over Inheritance
While inheritance is powerful, deep inheritance hierarchies can become fragile and hard to reason about. Modern JavaScript favors composition — building functionality by combining smaller, focused objects or mixins.
// Mixins — reusable behavior modules
const Serializable = {
toJSON() {
return JSON.stringify(this);
},
toLogString() {
return `[${this.constructor.name}] ${JSON.stringify(this)}`;
}
};
const Validatable = {
validate() {
for (const [key, value] of Object.entries(this)) {
if (value === null || value === undefined) {
throw new Error(`Validation failed: "${key}" is required`);
}
}
return true;
}
};
// Compose behaviors into a class
class Product {
constructor(name, price) {
this.name = name;
this.price = price;
}
}
Object.assign(Product.prototype, Serializable, Validatable);
const item = new Product('Keyboard', 79.99);
console.log(item.toLogString()); // [Product] {"name":"Keyboard","price":79.99}
console.log(item.validate()); // true
This approach gives you the flexibility to mix and match behaviors without being locked into a rigid class hierarchy. A Product can be Serializable and Validatable without either being a base class.
Prototypal vs. Classical OOP
It's worth understanding how JavaScript's approach differs from classical OOP languages:
| Aspect | Classical (Java/C++) | JavaScript (Prototypal) |
|---|---|---|
| Blueprint | Classes are rigid blueprints | Prototypes are live objects |
| Inheritance | Copy-based (class to instance) | Delegation (linked chain of objects) |
| Method Resolution | Compile-time | Runtime (prototype chain lookup) |
| Flexibility | Fixed at compile time | Dynamic — modify prototypes anytime |
| Multiple Inheritance | Interfaces / Abstract classes | Mixins / Object.assign |
JavaScript's prototypal model is more flexible — you can modify prototypes at runtime, compose behaviors freely, and create objects without classes at all using Object.create().
Conclusion
Object-Oriented Programming in JavaScript is a unique blend of prototypal mechanics and classical syntax. The class keyword gives us familiar structure, but under the hood, everything flows through the prototype chain.
The key takeaways:
- Objects are the fundamental building blocks. Everything in JavaScript is, or behaves like, an object.
- Prototypes power inheritance through delegation, not copying.
- Classes are syntactic sugar over constructor functions and prototypes.
- Encapsulation is enforced with
#private fields. - Composition (mixins) is often preferable to deep inheritance chains.
Mastering these concepts doesn't just make you a better JavaScript developer — it gives you the mental model to architect maintainable, scalable applications that hold up as complexity grows.