Object‑oriented programming (OOP) is a programming paradigm that models software design around “objects,” which encapsulate related data (properties) and behavior (methods). While JavaScript began as a primarily functional/prototypal language, modern versions (ES6+) offer robust syntax for classes, inheritance, and encapsulation. In this article, we’ll explore how to leverage OOP principles in JavaScript, covering both prototypal and class‑based approaches, diving into key concepts like encapsulation, inheritance, and polymorphism, and illustrating various patterns and best practices.
Table of Contents
Why OOP in JavaScript?
Maintainability & Modularity
Breaking complex functionality into discrete, self‑contained objects helps manage large codebases. Each object or class handles its own state and behavior, making reasoning, testing, and debugging more straightforward.Reusability
By defining classes or constructor functions, you can instantiate multiple copies (instances) that share common behavior. Shared behavior can be maintained in one place, reducing duplication.Organization of Code
In many projects—especially front‑end applications (e.g., React, Vue, Angular) or Node.js backends—using classes or modules helps organize code logically (e.g., Controllers, Models, Services). OOP promotes clear boundaries between components.Modeling Real‑World Domains
When domain entities have clear attributes and behaviors (e.g.,User
,Product
,Order
), modeling them as objects/classes is intuitive and aligns with domain‑driven design.Compatibility with Frameworks/Libraries
Many popular frameworks (e.g., React components, Angular services) either use or recommend class‑based structures. Understanding OOP in JS facilitates leveraging these frameworks effectively.
Core OOP Concepts
Before diving into JavaScript specifics, let’s review OOP’s foundational concepts:
Objects and Classes
- Object: A collection of related data (properties) and functionality (methods). In JS, everything that’s not a primitive (number, string, boolean, null, undefined, symbol, bigInt) is an object (arrays, functions, objects).
- Class (Blueprint): A template defining properties and methods; instances (objects) are created from a class. In JavaScript pre‑ES6, “classes” were approximated using constructor functions and s; ES6 introduced the
class
keyword as syntactic sugar over chains.
Encapsulation
- Definition: Bundling data (properties) and methods that operate on that data into one unit (object/class), hiding internal state and exposing only necessary parts.
- Benefits: Prevents external code from depending on internal implementation details, making refactoring safer and reducing unintended side effects.
Inheritance
- Definition: Capability of one object or class to derive properties and methods from another. The “child” or “subclass” inherits behavior from the “parent” or “superclass.”
- JS Implementation: Traditionally via chains; ES6 uses
extends
to simplify syntax. Inheritance promotes code reuse and logical hierarchies (e.g.,Animal
→Bird
→Penguin
).
Polymorphism
- Definition: Ability for different classes/objects to respond to the same method or message in their own way. For example, calling
draw()
on aCircle
orRectangle
instance, each implementsdraw()
differently. - JS Traits: Because JS is dynamically typed, polymorphism often emerges naturally—if two objects share method names, you can call those methods interchangeably (“duck typing”).
Abstraction
- Definition: Hiding internal complexity by exposing a simpler interface.
- Example: A
Database
class might expose methods likeconnect()
,query()
, andclose()
, while hiding the low‑level details of socket connections and SQL parsing.
Prototypal Inheritance vs. ES6 Classes
JavaScript has a dual heritage: it’s ‑based at its core, but since ES6 (ES2015), it offers class‑based syntax. It’s crucial to understand both.
Constructor Functions & s (Pre‑ES6)
Prior to ES6, “classes” in JS were created via constructor functions and the chain:
// Constructor Function
function Person(name, age) {
this.name = name;
this.age = age;
}
// Adding methods on the
Person..greet = function() {
console.log(`Hello, my name is ${this.name} and I'm ${this.age} years old.`);
};
// Instantiate
const alice = new Person('Alice', 30);
alice.greet(); // Hello, my name is Alice and I'm 30 years old.
- Every function in JS has a
property (an object).
- When you instantiate via
new Person(...)
, JavaScript:
- Creates a new empty object.
- Sets its internal
[[]]
(i.e.,__proto__
) toPerson.
. - Executes the
Person
function withthis
bound to the new object. - Returns
this
(the new object) unless an explicit object is returned.
Chain
If you call alice.greet()
, JS looks for greet
on alice
. If not found, it checks alice.__proto__
(which is Person.
), finds greet
, and invokes it with this
bound to alice
.
ES6 Class Syntax
ES6 introduced the class
keyword, which under the hood remains syntactic sugar on top of s:
class Person {
constructor(name, age) {
this.name = name;
this.age = age;
}
// Instance method
greet() {
console.log(`Hello, my name is ${this.name} and I'm ${this.age} years old.`);
}
// Static method
static isPerson(obj) {
return obj instanceof Person;
}
}
const bob = new Person('Bob', 25);
bob.greet(); // Hello, my name is Bob and I'm 25 years old.
console.log(Person.isPerson(bob)); // true
constructor
method: Called when creating a new instance (new Person(...)
).- Instance methods: Defined within the class body (outside
constructor
), automatically placed onPerson.
. - Static methods/properties: Prefixed with
static
, not available on instances; called directly on the class (Person.isPerson(...)
). - Private fields (ES2022+): Declared with
#
(e.g.,#secret
), accessible only within the class body. - Inheritance: Achieved via
extends
andsuper
(see below).
Encapsulation in JavaScript
Encapsulation is about restricting direct access to some of an object’s components. JavaScript provides multiple ways to achieve encapsulation.
Public Fields & Methods
By default, properties and methods defined on this
(in constructor or assigned to ) are publicly accessible:
class Rectangle {
constructor(width, height) {
this.width = width; // public
this.height = height; // public
}
area() {
return this.width * this.height;
}
}
const rect = new Rectangle(10, 5);
console.log(rect.width); // 10
rect.width = 20; // public can be modified
console.log(rect.area()); // 20 * 5 = 100
Private Fields (ES2022)
ES2022 introduced “hard” private fields using the #
prefix. They are only accessible within the class body:
class BankAccount {
#balance; // private field
constructor(initialBalance) {
this.#balance = initialBalance;
}
deposit(amount) {
if (amount > 0) {
this.#balance += amount;
console.log(`Deposited ${amount}, new balance: ${this.#balance}`);
}
}
withdraw(amount) {
if (amount <= this.#balance) {
this.#balance -= amount;
console.log(`Withdrew ${amount}, remaining: ${this.#balance}`);
} else {
console.log('Insufficient funds');
}
}
getBalance() {
return this.#balance;
}
}
const account = new BankAccount(500);
console.log(account.getBalance()); // 500
account.withdraw(100); // Withdrew 100, remaining: 400
console.log(account.#balance); // SyntaxError: Private field '#balance' must be declared in an enclosing class
Key Points:
- Private fields cannot be accessed or modified outside the class. Attempting to do so throws a syntax error.
- You can also define private methods with
#methodName() { ... }
.
Getters and Setters
Getters/setters allow controlled access to properties, enabling validation or computed values:
class Person {
#age;
constructor(name, age) {
this.name = name;
this.#age = age;
}
get age() {
return this.#age;
}
set age(newAge) {
if (newAge < 0) {
throw new Error('Age cannot be negative');
}
this.#age = newAge;
}
describe() {
console.log(`${this.name} is ${this.#age} years old.`);
}
}
const jane = new Person('Jane', 28);
console.log(jane.age); // 28 (getter)
jane.age = 30; // invokes setter
jane.describe(); // Jane is 30 years old.
// jane.age = -5; // throws Error: Age cannot be negative
- The getter (
get age()
) allows reading the private field#age
. - The setter (
set age(value)
) enforces validation before modifying#age
.
Symbols and WeakMaps (Pre‑Private Fields Workarounds)
Before #
fields, developers used Symbols or WeakMaps for pseudo‑private data:
// Using Symbol
const _password = Symbol('password');
class User {
constructor(username, password) {
this.username = username;
this[_password] = password;
}
verify(input) {
return input === this[_password];
}
}
const user = new User('admin', 's3cr3t');
console.log(user[_password]); // Not trivially discoverable; still somewhat accessible if you find the symbol
// Using WeakMap
const _secrets = new WeakMap();
class SecretHolder {
constructor(secret) {
_secrets.set(this, { secret });
}
reveal() {
return _secrets.get(this).secret;
}
}
const sh = new SecretHolder('hidden');
console.log(sh.reveal()); // hidden
console.log(_secrets.get(sh)); // only accessible if you have _secrets reference
While these patterns protected data from unintended external access, ES2022 private fields (#) are now the recommended approach.
Inheritance Patterns
Inheritance lets you build new classes based on existing ones, extending or overriding behavior.
Classical (ES6) Inheritance
class Animal {
constructor(name) {
this.name = name;
}
speak() {
console.log(`${this.name} makes a noise.`);
}
}
class Dog extends Animal {
constructor(name, breed) {
super(name); // Calls Animal’s constructor
this.breed = breed;
}
speak() { // Method overriding
console.log(`${this.name} barks!`);
}
describe() {
console.log(`${this.name} is a ${this.breed}.`);
}
}
const rex = new Dog('Rex', 'German Shepherd');
rex.speak(); // Rex barks!
rex.describe(); // Rex is a German Shepherd.
extends
: Establishes linkage.Dog..__proto__ === Animal.
.super(...)
: Invokes parent (superclass) constructor.- Inside child methods, you can also call
super.methodName(...)
to invoke the parent’s implementation:
class Cat extends Animal {
speak() {
super.speak(); // “Fluffy makes a noise.”
console.log(`${this.name} meows!`);
}
}
const fluffy = new Cat('Fluffy');
fluffy.speak();
// Output:
// Fluffy makes a noise.
// Fluffy meows!
Prototypal Inheritance Patterns
Under the hood, ES6 classes use the chain. You can also manually establish inheritance without class
:
function Vehicle(make, model) {
this.make = make;
this.model = model;
}
Vehicle..getInfo = function() {
return `${this.make} ${this.model}`;
};
function Car(make, model, year) {
Vehicle.call(this, make, model); // Equivalent of super()
this.year = year;
}
// Set Car. to an object whose is Vehicle.
Car. = Object.create(Vehicle.);
Car..constructor = Car;
Car..getFullInfo = function() {
return `${this.year} ${this.getInfo()}`;
};
const car = new Car('Toyota', 'Corolla', 2020);
console.log(car.getInfo()); // Toyota Corolla
console.log(car.getFullInfo()); // 2020 Toyota Corolla
Object.create(proto)
: Creates a new object with its internal[[]]
set toproto
, enabling inheritance.- Don’t forget to set
Car..constructor = Car
, so thatinstance.constructor
points to the correct function.
Mixins & Composition
Sometimes, strict single inheritance is too limiting. Mixins or composition let you share behavior across unrelated classes:
// Mixin to add logging behavior
const Logger = Base => class extends Base {
log(message) {
console.log(`[${this.constructor.name}]: ${message}`);
}
};
class Person {
constructor(name) {
this.name = name;
}
hello() {
console.log(`Hello, I'm ${this.name}`);
}
}
class Employee extends Logger(Person) {
constructor(name, role) {
super(name);
this.role = role;
}
work() {
this.log(`Working as ${this.role}`);
}
}
const emp = new Employee('Sam', 'Developer');
emp.hello(); // Hello, I'm Sam
emp.work(); // [Employee]: Working as Developer
- Mixins are functions that accept a base class and return a new class that extends it, injecting additional methods or behavior.
- Composition over inheritance: Sometimes it’s better to compose objects by aggregating smaller components rather than deep inheritance hierarchies.
Polymorphism in JavaScript
Polymorphism literally means “many forms.” In practice, it allows you to treat different objects through a common interface.
Method Overriding
Subclasses can override methods of their parent classes to provide specialized behavior:
class Shape {
area() {
throw new Error('Method "area()" must be implemented');
}
}
class Circle extends Shape {
constructor(radius) {
super();
this.radius = radius;
}
area() {
return Math.PI * this.radius ** 2;
}
}
class Rectangle extends Shape {
constructor(width, height) {
super();
this.width = width;
this.height = height;
}
area() {
return this.width * this.height;
}
}
const shapes = [new Circle(5), new Rectangle(4, 6)];
shapes.forEach(shape => {
console.log(shape.area()); // Each object’s specific implementation is invoked
});
Duck Typing
JavaScript’s dynamic nature means that if multiple objects share methods with the same name, you can treat them interchangeably without enforcing a strict inheritance relationship:
const dog = {
speak() {
console.log('Woof!');
}
};
const cat = {
speak() {
console.log('Meow!');
}
};
function makeItSpeak(entity) {
entity.speak(); // Doesn’t matter if it’s not a shared —just needs a speak() method
}
makeItSpeak(dog); // Woof!
makeItSpeak(cat); // Meow!
- This is often referred to as “If it walks like a duck and quacks like a duck, it’s a duck.”
- Interfaces (in TypeScript, for example) formalize this pattern; in pure JavaScript, you rely on runtime checks (e.g.,
typeof obj.speak === 'function'
).
Design Patterns & Best Practices
Applying proven design patterns and adhering to best practices helps ensure your OOP code is robust, maintainable, and easy to extend.
Factory Pattern
A factory is a function or class responsible for creating instances based on input parameters:
class Coin {
constructor(denomination) {
this.denomination = denomination;
}
}
class PaperMoney {
constructor(denomination) {
this.denomination = denomination;
}
}
function MoneyFactory(type, denom) {
switch (type) {
case 'coin':
return new Coin(denom);
case 'bill':
return new PaperMoney(denom);
default:
throw new Error('Invalid money type');
}
}
const penny = MoneyFactory('coin', 1);
const tenDollar = MoneyFactory('bill', 10);
- The factory abstracts away the logic of choosing which constructor to call, making client code simpler.
Singleton Pattern
Ensures a class has only one instance and provides a global point of access:
class Config {
constructor(settings) {
if (Config.instance) {
return Config.instance;
}
this.settings = settings;
Config.instance = this;
}
get(key) {
return this.settings[key];
}
set(key, value) {
this.settings[key] = value;
}
}
const configA = new Config({ theme: 'dark' });
const configB = new Config({}); // returns same instance as configA
console.log(configA === configB); // true
console.log(configB.get('theme')); // dark
- Avoid global variables; instead, use a singleton class/module to manage shared resources (e.g., logging, configuration).
Module Pattern
Encapsulates private state and exposes a public API. Traditionally implemented with Immediately Invoked Function Expressions (IIFEs) or ES6 modules:
// Using IIFE (older pattern)
const Counter = (function() {
let count = 0; // private
function increment() {
count++;
}
function decrement() {
count--;
}
function getCount() {
return count;
}
return {
increment,
decrement,
getCount
};
})();
Counter.increment();
Counter.increment();
console.log(Counter.getCount()); // 2
// console.log(Counter.count); // undefined
// Using ES6 module (counter.js)
// export let count = 0;
// export function increment() { count++; }
// export function getCount() { return count; }
// import { increment, getCount } from './counter.js';
- ES6 modules inherently provide encapsulation: only exported variables/functions are public.
Builder Pattern
Standardizes step‑by‑step object construction, especially when objects require numerous parameters or complex setup:
class Car {
constructor(builder) {
this.make = builder.make;
this.model = builder.model;
this.color = builder.color;
this.year = builder.year;
}
}
class CarBuilder {
setMake(make) {
this.make = make;
return this;
}
setModel(model) {
this.model = model;
return this;
}
setColor(color) {
this.color = color;
return this;
}
setYear(year) {
this.year = year;
return this;
}
build() {
if (!this.make || !this.model) {
throw new Error('Make and model are required');
}
return new Car(this);
}
}
// Usage:
const myCar = new CarBuilder()
.setMake('Tesla')
.setModel('Model S')
.setColor('Red')
.setYear(2022)
.build();
- Makes object creation readable, especially when many optional parameters exist.
SOLID Principles
While originally coined for classical OOP languages, SOLID applies well in JavaScript:
Single Responsibility Principle (SRP)
A class should have only one reason to change. E.g., aUser
class shouldn’t also handle HTTP requests; that responsibility belongs to a separate service.Open/Closed Principle (OCP)
Classes should be open for extension but closed for modification. Use inheritance or composition to add new behavior rather than modifying existing classes.Liskov Substitution Principle (LSP)
Subclasses should be substitutable for their base classes. Ifclass Dog extends Animal
, then anywhere you useAnimal
, you should be able to useDog
without unexpected behavior.Interface Segregation Principle (ISP)
Clients should not be forced to depend on methods they don’t use. In JS, this translates to keeping objects focused—avoid fat classes with unrelated methods.Dependency Inversion Principle (DIP)
High‑level modules should not depend on low‑level modules; both should depend on abstractions. In JS, rely on dependency injection (e.g., passing collaborators into constructors) rather than hardcoding dependencies.
Common Pitfalls & Anti‑Patterns
Deep Inheritance Hierarchies
Excessive subclassing can lead to brittle code. Prefer composition or mixins if you find multiple deep layers of inheritance.Overusing
this
Arrow functions do not have their ownthis
—they inherit from the lexical scope. Be careful when using arrow functions as methods; they might not bindthis
as expected.
class Counter {
count = 0;
// Using arrow function for a method—becomes an instance field
increment = () => {
this.count++;
};
}
const c = new Counter();
setTimeout(c.increment, 1000);
// This works correctly, because arrow function bound `this` lexically.
Modifying Built‑in s
Avoid extending or modifyingArray.
,Object.
, etc., as it can lead to conflicts or unexpected behavior across libraries.y Abstractions
Exposing internal data structures or implementation details (e.g., returning direct references to internal arrays) can allow external code to corrupt state. Always return copies or expose controlled interfaces.Inconsistent Method Binding
If passing instance methods as callbacks (array.map(user.logName)
), you can losethis
context. Solve with explicit binding:array.map(user.logName.bind(user))
, or define methods as arrow functions, or always call via the instance.
When to Use OOP in JavaScript
- Large‑Scale Applications: If your project has many interrelated components (e.g., models, controllers, services), OOP helps organize code.
- Domain Modeling: When representing real‑world entities (e.g.,
User
,Invoice
,Product
), OOP abstractions can closely mirror domain concepts. - Frameworks & Libraries: React (class components), Angular (services and components), and many Node.js frameworks often assume or encourage class‑based structures.
- Reusability & Extension: If you foresee the need to extend base functionality and override behavior, inheritance and polymorphism simplify those patterns.
However, for small scripts or simple utilities, a functional or module‑based approach might be simpler and more appropriate. Always choose the paradigm that best matches your problem’s complexity.
Conclusion
Object‑oriented programming in JavaScript blends its prototypal roots with class‑based syntactic sugar to offer a flexible and powerful paradigm. Whether you opt for chains or ES6 classes, understanding OOP fundamentals—encapsulation, inheritance, polymorphism, and abstraction—empowers you to design modular, reusable, and maintainable code. By following best practices, leveraging design patterns, and adhering to SOLID principles, you can write JavaScript applications that scale gracefully and evolve over time.
Top comments (1)
Nice summary of OOP in JavaScript—really helpful seeing the differences between prototypal and class-based code.