Object-Oriented Programming in JavaScript Explained
Published: Jan 4, 2024
Object-oriented programming (OOP) is an important programming paradigm that models real-world concepts. It structures code to focus on objects that contain related data and functionality. If you’re coming from a procedural programming background, OOP may take some getting used to. However, it offers many advantages for building and maintaining complex applications in JavaScript.
What is Object-Oriented Programming?
At a high-level, object-oriented programming organizes code into object definitions called classes. These classes act as blueprints to create individual instances called objects.
For example, you can have a Car
class that defines the generic properties and functions associated with cars. You can then create many specific car1
, car2
, etc. object instances from that class blueprint.
OOP revolves around several main concepts:
Encapsulation
This refers to bundling related properties (data) and methods (functions) within classes. The internal details are then hidden or encapsulated from external code. Objects only expose a public API to interact with them in limited ways.
This offers abstraction and reduces complexity for users of that object.
Inheritance
Classes can inherit commonly used state and behavior from other classes.
For example, a Truck
class can inherit the generic properties of Car
, with some added specifics of its own. This allows hierarchical relationships for specialized concepts derived from general ones.
Abstraction
This involves exposing only essential details and behavior while hiding unnecessary complexity. For example, a accelerate()
method abstracts away complex engine and traction mechanisms.
Polymorphism
Objects derived from the same parent class can be used interchangeably even if they behave differently based on type.
For example, Truck
and Car
offer their own accelerate()
methods handling their different engines under the hood. But they can be called polymorphically through their shared parent class interface.
OOP in JavaScript allows logically organizing code and modeling real-world relationships. We’ll explore the various object-oriented concepts in depth through the rest of this post.
Understanding Objects in JavaScript
Before jumping into object-oriented programming, let’s recap how to work with objects in JavaScript.
Objects in JavaScript are collections of keyed values similar to dictionaries or maps in other languages. The keys are equivalent to properties that reference values.
const person = {
name: "John",
age: 30
};
This person
object has a name
property storing the string "John"
and an age
property storing the number 30
.
We interact with these properties using dot notation or square bracket syntax:
person.name; // "John"
person["age"]; // 30
The values in an object can include primitive data types as well as nested objects and functions known as methods.
Methods allow objects to have associated functionality:
const person = {
name: "John",
greet() {
console.log("Hello!");
}
}
person.greet(); // Logs "Hello!"
This covers the basics of working with plain JavaScript objects. Next, we’ll explore ways of generating object instances programmatically.
Constructor Functions
Constructor functions serve as class blueprints to create objects through the new
keyword .
For example:
function Person(name, age) {
this.name = name;
this.age = age;
}
const john = new Person("John", 30);
This defines a Person()
constructor that initializes name
and age
properties on a new object when called with new
.
We call the Person()
constructor with new
to instantiate a specific john
person object with those properties.
Constructor functions implement a standard OOP pattern to define state through properties and behavior through methods. This allows constructing reusable components modeled after real concepts.
Prototypes and Inheritance
In JavaScript, inheritance works through prototypal inheritance instead of classes. Essentially, objects have a linked prototype
property pointing to another object. When a property isn’t found on an object, the JavaScript engine traverses up this prototype
chain to inherit from parent objects.
Let’s link prototypes to enable inheritance between Person
and Student
:
function Person(name) {
this.name = name;
}
// Add greet method to Person prototype
Person.prototype.greet = function() {
console.log(`Hello, my name is ${this.name}!`);
}
function Student(name, level) {
// Call parent constructor
Person.call(this, name);
this.level = level;
}
// Link prototypes
Student.prototype = Object.create(Person.prototype);
// Add introduce method
Student.prototype.introduce = function() {
console.log(`I'm a ${this.level} student.`);
}
const omar = new Student("Omar", "PhD");
This chains the prototypes so Student
can access methods from Person
via inheritance.
For example, we can call:
omar.greet(); // Inherited method
omar.introduce(); // Own method
This prototype linkage allows hierarchy between blueprints, code reuse through inheritance, and polymorphism.
The call() Method
Earlier, we used Person.call()
inside our Student
constructor. So what does .call()
actually do?
The .call()
method can invoke a function and set the meaning of this
in the function execution context.
Essentially, all JavaScript functions are actually methods that carry the .call()
and other built-in methods. For constructor functions, we leverage .call()
to execute the parent constructor but set the meaning of this
to the new child object.
Without .call()
:
function Person(name) {
// 'this' is the global object
this.name = name;
}
function Student(name) {
Person(name); // Just executes function without any 'this'
}
const omar = new Student("Omar");
console.log(omar.name); // Undefined
The Person
constructor sets its name
on the global object instead of Student
since it loses its execution context.
With .call()
:
function Student(name) {
// Set 'this' context to the new student
Person.call(this, name);
}
const omar = new Student("Omar");
console.log(omar.name); // "Omar"
Now Person()
sets name
on the intended Student
instance. Call allows “borrowing” functions while setting a specific context preserved by the this
keyword. All functions have access to call() and apply() for this purpose.
ES6 Classes
JavaScript ES6 introduced cleaner syntax for prototypes and inheritance through classes.
Classes serve as syntactic sugar to help developers from class-based backgrounds transition to JS.
Here is how we can convert our Person
and Student
code to use new ES6 classes:
class Person {
constructor(name) {
this.name = name;
}
greet() {
console.log(`Hello, my name is ${this.name}!`);
}
}
class Student extends Person {
constructor(name, level) {
// Call parent constructor
super(name);
this.level = level;
}
introduce() {
console.log(`I'm a ${this.level} student.`);
}
}
const omar = new Student("Omar", "PhD");
This class-based syntax offers a cleaner way to:
- Declare classes with an empty
class
keyword - Define constructors to initialize state
- Use
extends
for inheritance between classes for free prototype chaining - Call
super()
instead ofParent.call()
for less confusion
However, it still operates on prototypes behind the scenes. So understanding prototypal behavior is important even when using modern syntax like classes.
Constructor Functions vs Classes: When to Use Each
With both constructor functions and ES6 classes available for generating objects, when should you use each approach? Here are some general guidelines:
Use constructor functions when:
- You need to support older browsers lacking ES6 class compatibility
- You need dynamic inheritance hierarchies at runtime
- You want to create singletons by calling a constructor without
new
Use ES6 classes when:
- You want cleaner, more concise syntax
- You know inheritance hierarchies ahead of time
- Your codebase already uses classes so it’s consistent
- You only care about supporting modern browsers
In reality, classes compile down to constructor functions and prototypes. So classes don’t introduce new capabilities, just friendlier syntax.
Constructors provide lower-level control while classes act more like syntactic sugar. Consider performance vs developer experience trade-offs when choosing between plain functions and ES6 classes for modeling JavaScript objects.
Encapsulation
Encapsulation in OOP refers to bundling data and methods into a single unit (a class), while preventing external access to certain properties and methods. Encapsulation provides two major advantages:
Hiding Complexity
It allows hiding the internal implementation details of class. Other code does not need to know unnecessary complexity - it only interacts through a simple public interface. Encapsulation enables abstraction through limited access.
For example:
class BankAccount {
// Private property not accessible externally
#pinCode;
constructor(pin) {
this.#pinCode = pin;
}
// Public login method provides access check
login(pinAttempt) {
return this.#pinCode === pinAttempt;
}
}
const bankAccount = new BankAccount(1234);
// Pin code is not accessible directly
console.log(bankAccount.#pinCode); // Syntax error
// Login method grants indirect access
bankAccount.login(1234); // true
This keeps the #pinCode
encapsulated within BankAccount
instead of being directly accessible like typical public properties.
Data Access Control
It allows controlling where and how properties are accessed or modified. By limiting visibility and access points, encapsulation makes it easier to enforce invariants.
For example, a Person
class can check for valid ages being set rather than allowing direct modification of that property from anywhere:
class Person {
constructor(name, age) {
this.name = name;
this.setAge(age);
}
setAge(newAge) {
if (newAge > 0 && newAge < 150) {
this.age = newAge;
} else {
console.log("Not a valid age!");
}
}
}
const person = new Person("James", 33);
person.age = -10; // Not directly allowed!
person.setAge(-10); // Logs "Not a valid age!"
This restricts invalid age
values from being set by providing a single controlled point of access.
Encapsulation through methods like this facilitates stricter data constraints.
Polymorphism
Polymorphism in OOP refers to a child class extending a parent class while also overriding or extending its behavior. With polymorphism, we can interact with related objects in the same manner regardless of their actual underlying type.
Let’s take our vehicle example from earlier:
class Vehicle {
accelerate() {
console.log("Accelerating");
}
}
class Car extends Vehicle {
accelerate() {
super.accelerate();
console.log("Rolling on four wheels");
}
}
class Bike extends Vehicle {
accelerate() {
super.accelerate();
console.log("Rolling on two wheels");
}
}
// Polymorphic calls
const car = new Car();
const bike = new Bike();
car.accelerate();
bike.accelerate();
Even though Car
and Bike
have their own specific implementations, we can polymorphically call accelerate()
on both in the same way through their shared parent class interface.
This allows interacting with related objects generically without needing to know concrete sub-types, as long as they support the base interface.
JavaScript enables ad-hoc polymorphism dynamically through duck typing. Classes/prototypes facilitate stricter polymorphism with type inheritance hierarchies and overriding.
This polymorphic flexibility helps reduce code duplication across types following the DRY principle. Strict typing is not required as in languages like Java or C#.
Composition vs Inheritance
So far we’ve explored modeling relationships between entities using inheritance hierarchies with parent and child classes. However, another approach for code reuse in OOP is composition.
With composition, objects can reference other objects or functions through their properties to delegate some of their behavior.
Let’s contrast inheritance and composition:
Inheritance
We use base classes to define common logic:
class Animal {
makeNoise() {
console.log("Some noise!");
}
}
class Dog extends Animal {
// Inherits makeNoise method
}
Pros:
- Promotes hierarchy for conceptual relationships
- Reuses logic that applies to specializations
Cons:
- Brittle hierarchies with too many layers
- Can inherit unnecessary features
- Tight coupling
Composition
We define reusable components as stand-alone objects:
const noiseMaker = {
makeNoise() {
console.log("Noise!");
}
};
class Dog {
constructor() {
this.noiseMaker = noiseMaker;
}
}
Pros:
- Flexible part-whole relationships
- Loose coupling
- Swappable dependencies
Cons:
- No standard conceptual hierarchy
- Dependencies must be passed around manually
In practice composition tends to provide more maintainable code than inheritance alone, serving as a great companion paradigm to OO programming. Libraries like React favor composition over classes for building reusable UI components.
Object Relationships and Data Structures
So far we’ve seen modeling inheritance relationships between objects with prototypal chains. We can also leverage OOP principles to implement common data structures to organize information.
Modeling Object Associations
Objects can be associated with other objects to compose larger structures:
Aggregation
This represents a has-a relationship where an object contains a reference to another object:
class Author {
constructor(name) {
this.name = name;
}
}
class Book {
constructor(title, author) {
this.title = title;
this.author = author;
}
}
const author = new Author("John");
const book = new Book("Some Book", author);
book.author.name; // "John"
Here Book
aggregates an Author
object to model the real-world relationship between books and authors.
Composition
This is a stronger type of aggregation with tight ownership helping reuse. If a composite object gets destroyed, so do its belonging objects.
Implementing Data Structures
We can leverage OOP concepts like inheritance and aggregation to implement data structures like:
Linked Lists
A sequence of Node
objects linked through references:
class Node {
constructor(value, next) {
this.value = value;
this.next = next;
}
}
let head = new Node(1, null);
head.next = new Node(2, null);
Trees
Node hierarchy where each parent node references child nodes as branches:
A
/ | \
B C D
/ \
E F
We can build up these structures by composing objects together. OOP promotes thinking about relationships between components.
Modules and Namespaces
As JavaScript applications grow in scope, effectively organizing code becomes crucial to manage complexity. OOP principles provide logical segmentation of code into classes and objects. JavaScript also supports division of code at a file system level through modules and namespaces.
Motivation
Without modules or namespaces:
- Many application scripts get bundled into a large codebase
- Pollutes global namespace with many variables and functions
- Hard to track dependencies and interactions between entities
- Testing, maintaining, refactoring complex
Namespaces
Namespaces logically group related functionality under a single global variable to avoid collisions:
const MyNamespace = {
Util: {
calculateTotal(x) {
//...
}
},
UI: {
// UI methods
},
// Etc...
};
Namespaces use objects to provide separation without file system divisions.
Native ES Modules
JavaScript files can export
reusable entities and import
dependencies explicitly between files:
// Module 'Util.js'
export function calculateTotal(x) {
// ...
}
// Module 'App.js'
import { calculateTotal } from './Util.js';
This enforces clean imports/exports instead of global namespaces.
Modules work nicely with OOP by exporting classes and namespaces themselves can align with high-level objects. Clean namespaces and modules result in loosely coupled code critical for scaling JS apps.
Error Handling and Debugging
Robust error handling and debugging strategies are important for maintaining complex OOP systems.
Error Handling
Several best practices help with errors in OOP code:
- Exception handling with
try/catch
blocks catch execution errors gracefully - Well-defined error types that get thrown/caught help isolate issues
- Using custom error objects provides context for exceptions
- Handling errors centrally in parent classes and catch globally
For example:
class ValidationError extends Error {
// Custom error type
}
class Person {
constructor() {
try {
// Logic
} catch (err) {
throw new ValidationError(err);
}
}
}
try {
const person = new Person();
} catch (err) {
if (err instanceof ValidationError) {
// Handle validation specifically
} else {
// Other exceptions
}
}
This separates error handling concerns clearly across hierarchies.
Debugging OOP Systems
Debugging techniques useful for OOP include:
- Logging object method calls and property state
- Tracing application flow across objects/scopes with debuggers
- Checking prototypes and inheritance chains are properly configured
- Testing objects in isolation with unit tests (see next section)
The interconnected nature of classes means debugging requires tracking relationships between objects. Following good error handling practices goes hand in hand with easier debugging.
Testing and Maintainability
Testing helps catch errors and reduces debugging time through test suites targeting components.
Unit Testing Individual Classes
Each class can have dedicated unit tests validating object behavior in isolation:
const Person = require("./Person");
describe("Person", () => {
test("initialization", () => {
const person = new Person("Sarah", 23);
expect(person.name).toEqual("Sarah");
expect(person.age).toEqual(23);
});
});
This ensures classes work independently before integrating them into an app.
Facilitating Loose Coupling
Loosely coupled OOP code is easier to reconfigure and maintain over time. Strategies like:
- Using interface-based programming over concrete types
- Developing classes with single responsibilities
- Favoring small modules that do one thing well
These best practices align with principles like the Open/Closed Principle where classes are open for extension but closed for modification.
Well structured and tested OOP code goes hand in hand with more maintainable code.
Advanced OOP Concepts
We’ve covered core object-oriented principles in JavaScript. A few more advanced topics worth mentioning:
Abstract Classes & Interfaces
Abstract classes provide incomplete implementations leaving some methods unimplemented to be filled in by child classes:
abstract class Animal {
constructor(name) {
this.name = name;
}
eat() {
// Eat implementation
}
// Must be implemented by subclasses
abstract makeSound();
}
class Dog extends Animal {
constructor(name) {
super(name);
}
makeSound() {
console.log("Bark!");
}
}
Interfaces define structure but not implementation. Classes implement interfaces similar to contracts:
interface Walker {
walk();
}
class Person implements Walker {
// Must define walk() method
}
Design Patterns
Design patterns provide proven OOP solutions to common problems like:
- Singleton - Class only allowing a single object instance
- Factory - Creating objects without specifying exact type
- Observer - Event-based communication between objects
Patterns help build maintainable code by leveraging programming experience.
Functional Mixins
JavaScript uniquely allows extending OOP code through mixins:
const flyMixin = {
fly() {
console.log("Flying");
}
}
class Bird {
// ...
}
Object.assign(Bird.prototype, flyMixin);
This “mixes in” fly
method from a plain object into Bird prototype. JS flexibility allows functional and OOP blend.
Conclusion
We’ve explored main object-oriented concepts like encapsulation, inheritance, and polymorphism in JavaScript. OOP provides principles for managing code complexity through logical, real-world modeling of application structure and behavior.
JavaScript enables an adaptable approach to OOP that aligns with its dynamic nature. Developers can choose the right level of strictness through prototypes, classes, functional mixins and more.
As applications grow, OOP skills prove vital for clean architecture, maintenance, and working in teams. Mastering core object-oriented ideas unlocks JavaScript’s capabilities for large-scale development.
The language continues to rapidly adopt new syntax like classes to ease the transition from classical OOP. Understanding the core prototypes and inheritance mechanisms, however, remains essential.
With this solid grasp of theoretical and practical OOP knowledge, JavaScript developers can build robust, scalable applications.
Help Improve This Page
Spot an error or have a suggestion? This page is a Markdown file, making it easy to contribute:
- Click the "Edit on GitHub" button below
- Look for the pencil icon in the GitHub interface
- Make your changes and submit a pull request
Can't edit directly? Share your feedback or report issues in the comments below. We appreciate your input!