JavaScript•28 min read

JavaScript OOP Complete Guide (2026): Prototypes, Classes & SOLID

Master JavaScript OOP: prototype chain, classes, 'this' binding rules, tricky output puzzles, scratch polyfills, and production SOLID architecture.

Dev Kant Kumar
Dev Kant Kumar
September 30, 2026

30-Second Key Takeaways

TL;DR

Quick digest before you dive deep

JavaScript uses dynamic prototypal delegation: objects inherit directly from objects via [[Prototype]], not compile-time class blueprints.
The 'this' keyword is evaluated at call-site invocation following 4 precedence rules: new > explicit > implicit > default.
Methods belong on the prototype to share a single function memory reference across all instances rather than duplicating in heap.
JavaScript OOP Complete Guide: Prototypes, Classes, this, Polyfills & SOLID Architecture
Dev Kant Kumar
28 min read
Intermediate - Staff Engineer
COMPREHENSIVE 2026 DEEP DIVE•Full Interview Blueprint & Spec Analysis

Mastering Object-Oriented Programming in JavaScript

If you have ever prepared for senior or staff-level JavaScript interviews at companies like Google, Meta, Amazon, or high-growth tech startups, you already know one uncomfortable truth: Object-Oriented JavaScript is the number one topic where candidates stumble.

While Java and C++ operate on rigid, compile-time class blueprints, JavaScript was designed around a dynamic prototypal delegation model. When ES6 introduced the class keyword in 2015, many developers assumed JavaScript had transformed into a classical OOP language. In reality, ES6 classes are syntactic sugar layered directly over prototype chains and lexical environments.

Interviewers exploit this exact knowledge gap. They will ask you to predict tricky this outputs, explain the difference between prototype and __proto__, hand-code polyfills for new and bind, and architect production-grade Node.js backends using SOLID principles.

Under-The-Hood Mechanics

Step-by-step ECMAScript specification rules for property lookup, prototype chain resolution, and constructor invocation.

Interactive Predictions & Polyfills

Hand-implement new, bind, instanceof, and interactive output puzzles with live explanations.

Production Node.js & SOLID

Production patterns for error hierarchies, custom EventEmitters, dependency injection, and Repository/Service architecture.

How to navigate this guide

If you are preparing for an immediate interview, dive straight into Section 2 (Prototypes), Section 6 (`this` Keyword), and test yourself on Section 7 (Output Predictions). If you are designing scalable backend services, study Section 8 (Implementations), Section 9 (SOLID), and Section 10 (Node.js Backend).

1. OOP Fundamentals: JavaScript vs Classical Languages

Core paradigm shift, object instantiation patterns, the exact 4-step `new` algorithm, and defensive constructors.

What is OOP? Prototype-Based vs Class-Based

Object-Oriented Programming (OOP) is a software design paradigm organized around data structures (objects) containing properties and behaviors (methods), rather than isolated functions and imperative procedures.

In classical OOP languages like Java or C++, a Class is an immutable, compile-time blueprint stored in memory. Objects are direct instances cast from that blueprint. A class cannot modify its structure dynamically at runtime for all living instances.

In contrast, JavaScript is prototype-based. There are no static, binary-compiled classes in the V8 engine. Instead, objects link directly to other objects via hidden reference pointers ([[Prototype]]). If an object cannot find a requested property, it delegates the search up the chain.

Classical OOP (Java, C++, C#)
  • •Classes are blueprints: Distinct runtime entity from instances.
  • •Static inheritance: Class hierarchy defined at compile time.
  • •Copy-on-instantiation: Methods and fields are copied or indexed in a vtable.
Prototypal OOP (JavaScript)
  • •Objects inherit from objects: No separate class entity exists in engine memory.
  • •Dynamic live delegation: If prototype changes at runtime, all instances reflect it immediately.
  • •No vtable duplication: Delegation traverses the prototype link on cache miss.

The 6 Ways to Create Objects in JavaScript

Interviewers love asking candidates to enumerate every way to instantiate an object. Here is the complete spectrum with practical trade-offs:

jsobject-creation-patterns.js
// 1. Object Literal (Most common, fastest for singletons)
const userLiteral = {
name: "Dev Kant",
role: "Software Architect",
greet() { return `Hi, I'm ${this.name}`; }
};
 
// 2. Object constructor
const userObj = new Object();
userObj.name = "Dev Kant";
 
// 3. Constructor Function (Pre-ES6 standard OOP)
function UserConstructor(name, role) {
this.name = name;
this.role = role;
}
UserConstructor.prototype.greet = function() {
return `Hi, I'm ${this.name}`;
};
const user1 = new UserConstructor("Dev Kant", "Engineer");
 
// 4. Object.create() (Direct prototypal delegation without constructor)
const userProto = {
greet() { return `Hi, I'm ${this.name}`; }
};
const userCreated = Object.create(userProto);
userCreated.name = "Dev Kant";
 
// 5. ES6 Class (Modern standard syntax)
class UserClass {
constructor(name, role) {
this.name = name;
this.role = role;
}
greet() {
return `Hi, I'm ${this.name}`;
}
}
const userClassInstance = new UserClass("Dev Kant", "Engineer");
 
// 6. Factory Function (Encapsulation without 'this' or 'new')
function createUserFactory(name, role) {
return {
name,
role,
greet() { return `Hi, I'm ${name}`; }
};
}
const userFactoryInstance = createUserFactory("Dev Kant", "Engineer");
PatternPrototype LinkMemory CostPrimary Use Case
Object Literal ({})`Object.prototype`Low (Fastest)Singletons, DTOs, configs
Constructor Function`Constructor.prototype`Low (if methods on proto)Legacy codebases, libraries
Object.create(proto)Specified `proto`LowestPure delegation, `Object.create(null)` maps
ES6 Class`Class.prototype`Low (prototype shared)Modern web apps, enterprise design
Factory Function`Object.prototype`Higher (method per instance)True private state without `#`, closures

What Exactly Does the `new` Keyword Do? (Step-by-Step)

This is an absolute staple at top-tier companies. When an engine executes new Constructor(...args), four distinct phases happen sequentially:

ECMAScript [[Construct]] Internal Method Lifecycle

Spec Algorithm
1
Create Object

A new empty memory record is allocated: {}

→
2
Link Prototype

Internal [[Prototype]] is set to Constructor.prototype

→
3
Bind & Execute

Constructor called with this pointing to the new object

→
4
Return Resolution

If constructor returned an object, return that; else return the created instance

Here is how to reproduce this exact behavior in vanilla JavaScript:

jsmyNewPolyfill.js
// What happens under the hood when you execute: new Constructor(...args)
 
function myNew(Constructor, ...args) {
// Step 1: Create a brand new, empty plain JavaScript object
// Step 2: Link this new object's [[Prototype]] to Constructor.prototype
const instance = Object.create(Constructor.prototype);
 
// Step 3: Execute the Constructor function with 'this' bound to the new object
const result = Constructor.apply(instance, args);
 
// Step 4: Return result if it evaluated to an Object; otherwise return instance
const isObject = typeof result === 'object' && result !== null;
const isFunction = typeof result === 'function';
return (isObject || isFunction) ? result : instance;
}
 
// Verification:
function Person(name, age) {
this.name = name;
this.age = age;
}
Person.prototype.sayHello = function() {
return `Hello, I'm ${this.name}`;
};
 
const candidate = myNew(Person, "Alex", 29);
console.log(candidate.name); // "Alex"
console.log(candidate.sayHello()); // "Hello, I'm Alex"
console.log(candidate instanceof Person); // true
The Return Value Trap:If a constructor explicitly returns a primitive value (like return 42; or return "string";), JavaScript ignores it and returns the newly instantiated object. But if the constructor explicitly returns a reference object (like return { x: 1 }; or an array), JavaScript discards the newly created instance and returns that returned object!

What Happens If You Forget `new`?

Before ES6 classes, forgetting the new keyword was the number one cause of production memory bugs and global variable poisoning.

jsdefensive-constructor.js
// ❌ Problem: Forgetting 'new' in non-strict mode
function Developer(name) {
this.name = name; // Pollutes global window / global object!
}
const dev1 = Developer("Dev");
console.log(dev1); // undefined
// In browser non-strict: window.name === "Dev"!
 
// 🛡️ Solution 1: Strict mode ('use strict')
function DeveloperStrict(name) {
'use strict';
this.name = name; // Throws TypeError: Cannot set property 'name' of undefined
}
 
// 🛡️ Solution 2: Defensive constructor using new.target (ES6+)
function SafeDeveloper(name) {
if (!new.target) {
return new SafeDeveloper(name); // Self-invokes with 'new'
}
this.name = name;
}
const dev2 = SafeDeveloper("Dev"); // Works safely even without 'new'!
console.log(dev2.name); // "Dev"

2. Prototypes: The Holy Grail of JS Interviews

Spec definitions, chain resolution algorithm, memory allocation profiling, property shadowing, and ES2022 security.

`prototype` vs `__proto__` vs `[[Prototype]]`

Confusion between these three names is the primary reason candidates fail technical rounds. Let's establish unambiguous definitions:

Prototype Terminology Matrix

Spec Definition
[[Prototype]]
Internal Engine Slot

Hidden spec pointer inside every JavaScript object pointing to its prototype parent. Not directly accessible via standard property syntax.

Constructor.prototype
Function Property

An actual property on constructor functions and classes. Becomes the [[Prototype]] of instances created via new.

__proto__
Legacy Accessor

A getter/setter on Object.prototype that exposes [[Prototype]]. Deprecated in favor of Object.getPrototypeOf.

jsproto-vs-prototype.js
function Hero(name) {
this.name = name;
}
 
// 1. 'prototype' property:
// Exists ONLY on functions (except arrow functions)
// It is the blueprint attached to instances created via 'new'
Hero.prototype.attack = function() { return `${this.name} strikes!`; };
 
const hero = new Hero("Knight");
 
// 2. '[[Prototype]]':
// Internal, hidden slot defined by the ECMAScript specification.
// Represents the actual delegation link in memory.
 
// 3. '__proto__':
// Legacy accessor property (getter/setter on Object.prototype).
// Exposes [[Prototype]] to user code (deprecated in modern standards).
console.log(hero.__proto__ === Hero.prototype); // true
 
// 4. Modern, spec-compliant inspection:
console.log(Object.getPrototypeOf(hero) === Hero.prototype); // true
console.log(Hero.prototype.isPrototypeOf(hero)); // true

Why Put Methods on the Prototype Instead of Inside the Constructor?

Interviewers ask this to test whether you understand V8 heap allocation and garbage collection:

Methods inside Constructor

If you instantiate 10,000 objects, V8 allocates 10,000 unique closure function instances in RAM. Each closure retains its own scope chain, drastically inflating memory consumption.

10,000 instances = 10,000 function objects

Methods on Prototype

All 10,000 instances hold a lightweight reference to a single shared function object on the prototype object. Instantaneous execution with negligible memory overhead.

10,000 instances = 1 shared function object
jsmemory-benchmark.js
// ❌ Anti-pattern: Methods inside constructor
function ExpensiveUser(name) {
this.name = name;
// Allocated anew in heap for EVERY single instance!
this.save = function() {
return `Saving ${this.name}`;
};
}
const u1 = new ExpensiveUser("A");
const u2 = new ExpensiveUser("B");
console.log(u1.save === u2.save); // false (2 distinct functions in RAM!)
 
// ✅ Best practice: Method on prototype
function OptimizedUser(name) {
this.name = name;
}
// Allocated EXACTLY ONCE in memory regardless of how many instances exist!
OptimizedUser.prototype.save = function() {
return `Saving ${this.name}`;
};
const o1 = new OptimizedUser("A");
const o2 = new OptimizedUser("B");
console.log(o1.save === o2.save); // true (Single shared reference!)

Prototype Chain Resolution & Property Shadowing

When reading object.property, the engine performs the following lookup loop:

  1. Checks if property is an own property on the object.
  2. If absent, follows [[Prototype]] to the parent object.
  3. Recursively repeats this traversal until the property is found or it reaches null.
  4. If null is reached without finding the property, returns undefined.
jslookup-and-shadowing.js
const grandparent = { surname: "Stark", kingdom: "North" };
const parent = Object.create(grandparent);
parent.castle = "Winterfell";
 
const child = Object.create(parent);
child.name = "Arya";
 
// 1. Property Lookup Chain:
console.log(child.name); // "Arya" (Found directly on child - own property)
console.log(child.castle); // "Winterfell" (Not on child -> found on parent)
console.log(child.surname); // "Stark" (Not on parent -> found on grandparent)
console.log(child.dragon); // undefined (Reached Object.prototype -> null -> undefined)
 
// 2. Property Shadowing:
child.kingdom = "Braavos"; // Shadows grandparent.kingdom without modifying grandparent!
console.log(child.kingdom); // "Braavos" (Directly on child now)
console.log(grandparent.kingdom); // "North" (Unchanged!)
 
// 3. Shadowing Gotcha with Getters:
const protoWithGetter = {
get score() { return 100; }
};
const obj = Object.create(protoWithGetter);
// In strict mode, direct assignment throws TypeError: Cannot set property score which has only a getter!
// obj.score = 50;
// To force own property shadowing when prototype has read-only getter:
Object.defineProperty(obj, 'score', { value: 50, writable: true });
console.log(obj.score); // 50

`hasOwnProperty` vs `in` vs `Object.hasOwn` (ES2022)

Senior candidates are expected to know why calling obj.hasOwnProperty() is an anti-pattern in modern production applications, and why ECMAScript introduced Object.hasOwn():

jssafe-property-checking.js
// 🛡️ Safe property checking: in vs hasOwnProperty vs Object.hasOwn (ES2022)
 
const normalObj = { title: "Architect" };
console.log("title" in normalObj); // true (checks own + prototype chain)
console.log("toString" in normalObj); // true (from Object.prototype!)
console.log(normalObj.hasOwnProperty("title")); // true (checks only own properties)
console.log(normalObj.hasOwnProperty("toString")); // false
 
// ⚠️ Vulnerability 1: Object.create(null) has NO Object.prototype methods!
const nullProtoMap = Object.create(null);
nullProtoMap.key = "value";
// nullProtoMap.hasOwnProperty("key"); // 💥 TypeError: nullProtoMap.hasOwnProperty is not a function!
 
// ⚠️ Vulnerability 2: Malicious object can shadow hasOwnProperty
const malicious = {
hasOwnProperty: () => false,
secret: "42"
};
console.log(malicious.hasOwnProperty("secret")); // false (Compromised!)
 
// ✅ Modern ES2022 Standard: Object.hasOwn()
// Works on null-prototype objects and cannot be shadowed by object instances!
console.log(Object.hasOwn(nullProtoMap, "key")); // true
console.log(Object.hasOwn(malicious, "secret")); // true

What is at the End of Every Prototype Chain? Why `Object.create(null)`?

At the top of the standard chain resides Object.prototype. And what is its prototype? null.

jsnull-prototype.js
// What is at the absolute end of every prototype chain?
const basic = {};
const proto1 = Object.getPrototypeOf(basic); // Object.prototype
const proto2 = Object.getPrototypeOf(proto1); // null
 
console.log(proto1 === Object.prototype); // true
console.log(proto2); // null (The terminal sentinel of the engine)
 
// ⚡ Why use Object.create(null)?
const safeDict = Object.create(null);
safeDict["constructor"] = "developer";
safeDict["__proto__"] = "clean";
 
console.log(safeDict.constructor); // "developer" (No inherited Function constructor!)
console.log(Object.getPrototypeOf(safeDict)); // null
// Immune to prototype pollution attacks because no prototype exists!

`instanceof` vs `typeof` vs `isPrototypeOf`

How do these three operators differ when verifying object hierarchy?

  • typeof: Operates on primitive types ("string", "number", "function", "undefined"). Fails on objects: typeof [] === 'object' and typeof null === 'object' (historic JS bug).
  • instanceof: Checks if Constructor.prototype exists anywhere along the object's prototype chain. Limitation: Fails across different iframes/execution contexts (realms) because each iframe has its own global Array.prototype!
  • isPrototypeOf: Method on any prototype object. Directly checks whether that object exists in the target's prototype chain: Array.prototype.isPrototypeOf([]) === true. Works even without constructors!

3. Classes (ES6+): Syntactic Sugar or True Paradigm?

Under-the-hood engine proof, TDZ hoisting semantics, hard private fields (`#`), brand checking, and static initialization blocks.

Are Classes Real or Syntactic Sugar Over Prototypes?

The canonical answer in an interview: Classes are syntactic sugar over prototypal inheritance, BUT with crucial engine-level guarantees and behavioral constraints.

jsclass-internals.js
class User {
constructor(name) {
this.name = name;
}
sayHi() {
return `Hi ${this.name}`;
}
}
 
// 1. Proof that User is a function:
console.log(typeof User); // "function"
 
// 2. Proof that methods live on User.prototype:
console.log(User.prototype.sayHi !== undefined); // true
console.log(User.prototype.constructor === User); // true
 
// 3. Difference 1: Class methods are non-enumerable by default!
console.log(Object.keys(User.prototype)); // [] (In ES5 constructor, sayHi would show up!)
 
// 4. Difference 2: Classes ALWAYS execute in strict mode
// 5. Difference 3: Class constructor cannot be invoked without 'new'
try {
User("Dev"); // 💥 TypeError: Class constructor User cannot be invoked without 'new'
} catch (e) {
console.log(e.message);
}
BehaviorES5 Constructor FunctionES6 Class
Typetypeof F === 'function'typeof C === 'function'
Call without `new`Permitted (mutates global in non-strict)Throws TypeError (`[[IsConstructor]]` flag)
Method EnumerabilityEnumerable (`enumerable: true`)Non-enumerable (`enumerable: false`)
Execution ModeSloppy mode unless explicit 'use strict'Always strict mode inside class body
HoistingFunction declaration fully hoistedHoisted but TDZ (Temporal Dead Zone)

Are Classes Hoisted? (Temporal Dead Zone Explained)

Yes, classes are hoisted by the JavaScript parser, but unlike function declarations, they are initialized to an uninitialized state in the Temporal Dead Zone (TDZ). Accessing a class before its declaration line results in a ReferenceError:

jsclass-tdz.js
// Functions are hoisted completely to top of scope:
sayHello(); // Output: "Hello" (Valid!)
function sayHello() {
console.log("Hello");
}
 
// Classes are hoisted but placed in the Temporal Dead Zone (TDZ):
try {
const p = new Person(); // 💥 ReferenceError: Cannot access 'Person' before initialization
} catch (e) {
console.log(e.name); // "ReferenceError"
}
 
class Person {
constructor() {
this.role = "Architect";
}
}

Hard Private Fields (`#x`) vs `_x` Convention

For two decades, JavaScript developers used the underscore prefix (e.g. this._balance = 1000) to communicate private intent. But this was merely a soft convention; anyone could inspect or mutate _balance at runtime.

Modern ECMAScript (ES2022+) provides true hard privacy with the `#` prefix:

jshard-private-fields.js
class BankAccount {
// 1. Public class field (ES2022)
currency = "USD";
 
// 2. True Private field (ES2022) - Stored in internal [[PrivateElements]]
#balance = 0;
#accountNumber;
 
// 3. Static private field
static #totalAccounts = 0;
 
constructor(initialBalance, accNo) {
this.#balance = initialBalance;
this.#accountNumber = accNo;
BankAccount.#totalAccounts++;
}
 
// 4. Private method
#auditLog(action, amount) {
console.log(`Audit: ${action} of ${amount}`);
}
 
// 5. Public methods accessing private state
deposit(amount) {
if (amount <= 0) throw new Error("Invalid deposit");
this.#balance += amount;
this.#auditLog("DEPOSIT", amount);
}
 
// 6. Getter & Setter
get balance() {
return this.#balance;
}
 
// 7. Private brand check using 'in' operator (ES2022)
static isBankAccount(obj) {
return #balance in obj; // Returns true ONLY if obj has the true #balance field!
}
}
 
const acc = new BankAccount(1000, "ACC-9921");
acc.deposit(500);
console.log(acc.balance); // 1500
 
// Hard privacy enforcement:
// console.log(acc.#balance); // 💥 SyntaxError: Private field '#balance' must be declared in an enclosing class
console.log(Object.keys(acc)); // ['currency'] (#balance is completely invisible!)
console.log(BankAccount.isBankAccount(acc)); // true
console.log(BankAccount.isBankAccount({ balance: 1500 })); // false (Brand check passes!)
Why Private Fields Are Superior to Symbols or Closures:Unlike Symbols (Object.getOwnPropertySymbols() reveals them) and closures (which cannot be shared across methods in separate instances without memory duplication), #private fields are stored in a dedicated internal slot ([[PrivateElements]]) on the object instance. They are strictly inaccessible outside the lexical text of the class body!

Static Blocks (`static { ... }`) & Class Expressions

In ES2022, JavaScript introduced Static Initialization Blocks. They provide a dedicated execution scope that runs once during class evaluation, allowing you to configure static properties, perform try/catch setup, or share private fields with external helper functions:

jsstatic-initialization-blocks.js
// ES2022 Static Initialization Blocks
class DatabaseConnection {
static #connectionString;
static config = {};
 
// Static initialization block runs once when the class is defined
static {
try {
const env = process.env.NODE_ENV || 'development';
DatabaseConnection.#connectionString = `postgres://db-cluster-${env}:5432`;
DatabaseConnection.config = { poolSize: 20, timeout: 5000 };
} catch (err) {
console.error("Failed to initialize database config", err);
}
}
 
static getUri() {
return DatabaseConnection.#connectionString;
}
}
 
console.log(DatabaseConnection.getUri()); // postgres://db-cluster-development:5432

4. Inheritance: ES6 `extends` vs ES5 Prototypal Wiring

`super()` allocation mechanics, ES5 4-step canonical pattern, extending built-ins (`Array`, `Error`), and Composition vs Mixins.

`extends` and `super`: Modern Subclassing

In ES6+, subclassing is declared using the extends keyword, and access to parent methods or constructors is handled through super:

jses6-inheritance.js
class Vehicle {
constructor(make, model) {
this.make = make;
this.model = model;
}
 
describe() {
return `${this.make} ${this.model}`;
}
}
 
class ElectricCar extends Vehicle {
constructor(make, model, batteryCapacity) {
// ⚠️ MUST call super() BEFORE accessing 'this'!
super(make, model);
this.batteryCapacity = batteryCapacity;
}
 
// Method overriding
describe() {
// Calling parent method via super.method()
return `${super.describe()} with ${this.batteryCapacity}kWh battery`;
}
}
 
const tesla = new ElectricCar("Tesla", "Model S", 100);
console.log(tesla.describe());
// "Tesla Model S with 100kWh battery"

Why MUST You Call `super()` Before Using `this`?

Almost every mid-to-senior interview will probe this question. Most candidates guess: "Because you need to copy the parent's properties first." That is incorrect.

Here is the exact ECMAScript engine truth:

jssuper-before-this-engine-rule.js
// 🔍 WHY must you call super() before accessing 'this'?
// In ES5: The Child constructor allocates the new object instance,
// then calls Parent.call(this).
 
// In ES6 derived classes:
// The derived class constructor does NOT allocate 'this' at all!
// Instead, the BASE CLASS constructor is responsible for creating
// the instance. 'super()' triggers the base constructor, which initializes
// 'this' and passes it down to the derived class.
 
class Parent {
constructor() {
// Step 1: Base constructor allocates object
this.initializedBy = "Parent";
}
}
 
class Child extends Parent {
constructor() {
// console.log(this); // 💥 ReferenceError: Must call super constructor in derived class before accessing 'this'
super();
console.log(this.initializedBy); // "Parent" (Now 'this' exists!)
this.childField = true;
}
}
Specification Note:In standard functions, this is bound when entering the function. In a derived class constructor, the environment record has an internal field [[ThisBindingStatus]] initialized to "uninitialized". Calling super() invokes the superclass constructor, returns the object instance, and sets [[ThisBindingStatus]] to "initialized". Accessing this prior to that causes the engine to throw a ReferenceError.

How Inheritance Worked in ES5 (The 4-Step Classic Pattern)

Before class and extends existed, JavaScript engineers wired inheritance manually. You will frequently be asked to write this from memory on a whiteboard or coderpad:

jses5-inheritance-pattern.js
// 🏛️ The Complete ES5 Prototypal Inheritance Pattern
function Animal(name) {
this.name = name;
}
Animal.prototype.speak = function() {
return `${this.name} makes a noise.`;
};
 
function Dog(name, breed) {
// Step 1: Borrow constructor to initialize parent fields on child instance
Animal.call(this, name);
this.breed = breed;
}
 
// Step 2: Link Child.prototype to Animal.prototype via Object.create
// ❌ DO NOT DO: Dog.prototype = new Animal(); (executes constructor prematurely!)
// ❌ DO NOT DO: Dog.prototype = Animal.prototype; (mutating Dog mutates Animal!)
Dog.prototype = Object.create(Animal.prototype);
 
// Step 3: Repair the constructor pointer (Object.create overwrote it!)
Dog.prototype.constructor = Dog;
 
// Step 4: Add child-specific methods AFTER linking prototype
Dog.prototype.bark = function() {
return `${this.name} barks loudly!`;
};
 
const d = new Dog("Rex", "German Shepherd");
console.log(d.speak()); // "Rex makes a noise."
console.log(d.bark()); // "Rex barks loudly!"
console.log(d instanceof Dog); // true
console.log(d instanceof Animal); // true
console.log(d.constructor === Dog); // true

Extending Built-ins (`Array`, `Error`, `Map`)

In ES5, subclassing built-ins like Array or Error was famously broken because internal array slots like [[DefineOwnProperty]] and magic length recalculations could not be inherited properly.

In ES6+, classes natively support extending built-ins because the base constructor initializes the object memory layout first!

jsextending-builtins.js
// 🚀 Extending Built-in JavaScript Classes (Array, Error, Map)
 
// 1. Custom Array with utility methods
class QueryCollection extends Array {
first() {
return this[0];
}
last() {
return this[this.length - 1];
}
// Subclassing symbol to control returned instance type in map/filter
static get [Symbol.species]() {
return QueryCollection;
}
}
 
const list = new QueryCollection(10, 20, 30);
console.log(list.first()); // 10
console.log(list.last()); // 30
const doubled = list.map(x => x * 2);
console.log(doubled instanceof QueryCollection); // true!
 
// 2. Custom Production Error Hierarchy
class DomainError extends Error {
constructor(message, statusCode = 400) {
super(message);
this.name = this.constructor.name;
this.statusCode = statusCode;
// Captures V8 stack trace excluding constructor call site
if (Error.captureStackTrace) {
Error.captureStackTrace(this, this.constructor);
}
}
}
 
class UserNotFoundError extends DomainError {
constructor(userId) {
super(`User with ID ${userId} was not found`, 404);
}
}
 
const err = new UserNotFoundError("usr_99");
console.log(err.name); // "UserNotFoundError"
console.log(err.statusCode); // 404
console.log(err instanceof DomainError); // true
console.log(err instanceof Error); // true

Multiple Inheritance via Mixins & "Composition over Inheritance"

JavaScript does not support multiple inheritance. A class can have only one direct prototype. To share behavior across unrelated class hierarchies without deep inheritance trees, we use Factory Mixins:

jsclass-mixins.js
// 🧩 Mixins: Achieving Multiple Inheritance in JavaScript
 
// Mixin 1: Logging capability
const LoggerMixin = (BaseClass) => class extends BaseClass {
log(message) {
console.log(`[${this.constructor.name}] ${message}`);
}
};
 
// Mixin 2: Serializable JSON capability
const SerializableMixin = (BaseClass) => class extends BaseClass {
toJSON() {
return JSON.stringify(this);
}
};
 
// Base entity
class UserEntity {
constructor(id, name) {
this.id = id;
this.name = name;
}
}
 
// Compose mixins in a clean pipeline
class EnhancedUser extends LoggerMixin(SerializableMixin(UserEntity)) {}
 
const user = new EnhancedUser("u100", "Dev Kant");
user.log("Created successfully"); // "[EnhancedUser] Created successfully"
console.log(user.toJSON()); // "{"id":"u100","name":"Dev Kant"}"
Composition vs Inheritance: When to Prefer Which

The famous Gang of Four guideline states: "Favor object composition over class inheritance."

Inheritance (is-a):Use only for shallow, true domain taxonomies (e.g. HttpError extends Error). Deep inheritance trees suffer from the Fragile Base Class problem, where modifying a parent method unexpectedly breaks distant descendants.
Composition (has-a):Assemble small, focused modules. A User doesn't inherit from Mailer and Validator; it accepts or instantiates them as dependencies. Easier to unit test, mock, and refactor.

5. The Four Pillars of OOP in JavaScript

Encapsulation mechanisms, simulating Abstract classes via `new.target`, Polymorphism through Duck Typing, and Overloading workarounds.

1. Encapsulation: Keeping Secrets Secret

Encapsulation is the bundling of data and the methods that operate on that data into a single unit, while restricting direct access to internal implementation details.

JavaScript offers 4 distinct ways to achieve encapsulation, each with unique trade-offs:

jsencapsulation-patterns.js
// 🛡️ 1. Encapsulation Approaches in JavaScript
 
// Approach A: Closures inside factory functions (Purest private state)
function createCounter() {
let count = 0; // Private variable trapped in lexical environment
return {
increment() { return ++count; },
get value() { return count; }
};
}
 
// Approach B: WeakMap (Common library pattern for privacy without leaking memory)
const _privateKey = new WeakMap();
class SecureVault {
constructor(secret) {
_privateKey.set(this, { secret });
}
reveal() {
return _privateKey.get(this).secret;
}
}
 
// Approach C: Modern #private class fields (Engine-enforced)
class ModernVault {
#secret;
constructor(secret) {
this.#secret = secret;
}
reveal() {
return this.#secret;
}
}
 
// Approach D: Object.freeze() and Object.defineProperty (Immutability)
const immutableConfig = Object.freeze({
apiUrl: "https://api.domain.com",
timeoutMs: 5000
});
// immutableConfig.timeoutMs = 9999; // Throws error in strict mode!

2. Abstraction: Simulating Abstract Classes with `new.target`

Unlike TypeScript or Java, JavaScript does not have an abstract keyword. However, you can enforce abstract classes and interfaces at runtime using the standard new.target meta-property:

jsabstract-class-simulation.js
// 👁️ 2. Abstraction: Simulating Abstract Classes with new.target
 
class AbstractDatabaseClient {
constructor() {
// Prevent direct instantiation of the abstract base class
if (new.target === AbstractDatabaseClient) {
throw new TypeError("Cannot construct AbstractDatabaseClient instances directly.");
}
}
 
// Abstract method contract
connect() {
throw new Error("Method 'connect()' must be implemented by subclass.");
}
 
query(sql) {
throw new Error("Method 'query()' must be implemented by subclass.");
}
}
 
class PostgresClient extends AbstractDatabaseClient {
connect() {
return "Connected to PostgreSQL server.";
}
query(sql) {
return `Executing "${sql}" on Postgres cluster.`;
}
}
 
try {
new AbstractDatabaseClient(); // 💥 TypeError: Cannot construct AbstractDatabaseClient instances directly.
} catch (e) {
console.log(e.message);
}
 
const pg = new PostgresClient();
console.log(pg.connect()); // "Connected to PostgreSQL server."

3. Polymorphism: Duck Typing & Overloading Fakes

Polymorphism means "many forms" — the ability of different objects to respond to the same method call with distinct, customized behaviors.

In JavaScript, polymorphism is achieved through Method Overriding and Duck Typing. Because JavaScript does not support static signature overloading (defining multiple methods with the same name but different parameters), we fake overloading via argument checking:

jspolymorphism-and-duck-typing.js
// 🎭 3. Polymorphism: Overriding, Duck Typing, and Faking Method Overloading
 
// Pattern 1: Method Overriding (Subtype Polymorphism)
class Shape {
calculateArea() { return 0; }
}
class Circle extends Shape {
constructor(radius) { super(); this.radius = radius; }
calculateArea() { return Math.PI * (this.radius ** 2); }
}
class Square extends Shape {
constructor(side) { super(); this.side = side; }
calculateArea() { return this.side * this.side; }
}
 
const shapes = [new Circle(10), new Square(5)];
shapes.forEach(s => console.log(s.calculateArea())); // 314.159, 25
 
// Pattern 2: Duck Typing ("If it walks like a duck and quacks like a duck...")
function renderWidget(printable) {
if (typeof printable.render !== 'function') {
throw new TypeError("Object does not conform to Renderable duck-type contract!");
}
return printable.render();
}
 
// Pattern 3: Faking Method Overloading in JS
// JavaScript has NO compile-time function overloading with multiple signatures!
// We simulate it by checking argument types and arity:
class OverloadSimulator {
search(...args) {
if (args.length === 1 && typeof args[0] === 'string') {
return `Searching by keyword: "${args[0]}"`;
}
if (args.length === 1 && typeof args[0] === 'number') {
return `Searching by record ID: #${args[0]}`;
}
if (args.length === 2 && typeof args[0] === 'string' && typeof args[1] === 'object') {
return `Searching "${args[0]}" with filter: ${JSON.stringify(args[1])}`;
}
throw new TypeError("Invalid search parameters");
}
}
 
const searchEngine = new OverloadSimulator();
console.log(searchEngine.search("JavaScript OOP")); // keyword
console.log(searchEngine.search(1004)); // record ID

4. Inheritance: Dynamic Prototypal vs Static Classical

In classical OOP, inheritance is a copy-down operation from parent class to child class. In JavaScript, inheritance is an upward delegation pointer. The child does not copy the parent's properties; it dynamically delegates property lookups to the parent object.

6. The `this` Keyword: The Engine's Binding Algorithm

The 4 binding rules in order of precedence, callback context loss, arrow class field memory profiling, and handwritten `bind`.

The Four Binding Rules (Order of Precedence)

In JavaScript, this is not authored at declaration time; it is evaluated at call-site invocation time based on 4 strict rules:

This Binding Precedence Order

V8 Evaluation Flow
1. Highest Priority
`new` Binding

`new Constructor()` sets `this` to newly created instance

→
2. Second Priority
Explicit Binding

`call()`, `apply()`, or `bind()` forces target context

→
3. Third Priority
Implicit Binding

`obj.method()` sets `this` to the preceding object `obj`

→
4. Fallback
Default Binding

Standalone call: `globalThis` in sloppy, `undefined` in strict

jsthis-four-rules.js
// Rule 1: Default Binding (Standalone function call)
function showDefault() {
console.log(this);
}
showDefault(); // In non-strict: window / globalThis; In strict mode ('use strict'): undefined
 
// Rule 2: Implicit Binding (Invoked via object context)
const engineer = {
name: "Dev Kant",
code() {
return `${this.name} is building features.`;
}
};
console.log(engineer.code()); // "Dev Kant is building features." (this === engineer)
 
// Rule 3: Explicit Binding (call, apply, bind)
function introduce(greeting, punctuation) {
return `${greeting}, I am ${this.name}${punctuation}`;
}
const person = { name: "Sarah" };
 
// call: passes arguments individually
console.log(introduce.call(person, "Hello", "!")); // "Hello, I am Sarah!"
 
// apply: passes arguments as an array
console.log(introduce.apply(person, ["Greetings", "."])); // "Greetings, I am Sarah."
 
// bind: returns a new permanently bound function
const boundIntro = introduce.bind(person, "Hey");
console.log(boundIntro("!!")); // "Hey, I am Sarah!!"
 
// Rule 4: new Binding (Constructed via 'new')
function User(name) {
this.name = name; // 'this' is bound to the brand-new object created by 'new'
}
const user = new User("Alex");
console.log(user.name); // "Alex"

Losing `this` When Passing Methods as Callbacks

When you pass a method reference into a callback consumer like setTimeout, an event listener, or Array.prototype.map, you are passing only the raw function reference, stripping away its implicit object context:

jslosing-this-context.js
// ⚠️ The #1 Frontend Bug: Losing 'this' in callbacks
 
class Timer {
constructor() {
this.seconds = 0;
}
 
// Regular method
start() {
// ❌ WRONG: Passing regular method to setTimeout
setTimeout(this.tick, 1000);
// In setTimeout, tick() is invoked as a plain function -> this === window / undefined!
}
 
tick() {
// console.log(this.seconds++); // 💥 TypeError: Cannot read properties of undefined
}
}
 
// 🛡️ The 3 Ways to Fix Lost 'this':
 
class FixedTimer {
constructor() {
this.seconds = 0;
// Fix 1: Explicitly bind in constructor (Single function on prototype, safe context)
this.tick1 = this.tick1.bind(this);
}
 
tick1() {
this.seconds++;
}
 
// Fix 2: Wrap in arrow function at call-site
start2() {
setTimeout(() => this.tick1(), 1000); // Arrow captures lexical 'this'
}
 
// Fix 3: Arrow function class field (Auto-bound per instance)
tick3 = () => {
this.seconds++;
};
}

Arrow Function Class Fields: Pros vs Cons (The Memory Tax)

Many React developers use arrow functions for all class methods: handleClick = () => { ... }. While convenient, interviewers look for candidates who understand the hidden architectural trade-offs:

jsarrow-class-field-benchmark.js
// ⚖️ Arrow Functions in Classes: Memory & Inheritance Trade-offs
 
class BadService {
// Arrow function field:
// PRO: Automatically auto-bound, never loses 'this' when passed as callback.
// CON 1: Placed on EVERY INSTANCE, NOT on the prototype!
// CON 2: Cannot be invoked via super.fetchData()!
fetchData = () => {
return "Data payload";
};
}
 
class GoodService {
// Regular prototype method:
// PRO 1: Stored once on GoodService.prototype (Zero memory waste).
// PRO 2: Fully overrideable in subclasses via super.fetchData().
fetchData() {
return "Data payload";
}
}
 
const s1 = new BadService();
const s2 = new BadService();
console.log(s1.fetchData === s2.fetchData); // false (Two different function instances in RAM!)
 
const g1 = new GoodService();
const g2 = new GoodService();
console.log(g1.fetchData === g2.fetchData); // true (Shared prototype reference!)

Writing Your Own `Function.prototype.bind`

Can you implement a compliant bind polyfill that handles currying and respects the new operator override?

jscustomBindPolyfill.js
// 🛠️ Hand-coding Function.prototype.bind (The Senior Interview Polyfill)
 
Function.prototype.myBind = function(context, ...boundArgs) {
if (typeof this !== 'function') {
throw new TypeError("Function.prototype.myBind must be called on a function");
}
 
const originalFn = this;
 
function boundFunction(...callArgs) {
// If boundFunction is invoked with 'new', 'this' should be the newly constructed instance,
// NOT the bound context! (Spec requirement: new overrides bind)
const isNew = this instanceof boundFunction;
const targetThis = isNew ? this : context;
return originalFn.apply(targetThis, [...boundArgs, ...callArgs]);
}
 
// Maintain prototype chain for 'new' operator
if (originalFn.prototype) {
boundFunction.prototype = Object.create(originalFn.prototype);
}
 
return boundFunction;
};
 
// Verification:
function greet(prefix, suffix) {
return `${prefix} ${this.name} ${suffix}`;
}
const ctx = { name: "Dev Kant" };
const myBound = greet.myBind(ctx, "Welcome");
console.log(myBound("to the team!")); // "Welcome Dev Kant to the team!"

7. Tricky Output Predictions: The Interview Gauntlet

Battle-tested interview code puzzles. Test your mental model, inspect the exact console output, and uncover the specification reasons.

Interviewers rarely ask for textbook definitions. Instead, they present short snippets of JavaScript code and ask: "What gets logged to the console, and why?"

1

The Classic Prototype & Detached Method Puzzle

Tricky
jsconsole-test.js
function Person(name) {
this.name = name;
}
Person.prototype.greet = function () {
return this.name;
};
 
const p = new Person("A");
const g = p.greet;
 
console.log(g());
console.log(p.constructor);
console.log(p.__proto__ === Person.prototype);
console.log(Person.__proto__ === Function.prototype);

Think through the execution steps before checking

2

Mutating Prototype After Instantiation

Medium
jsconsole-test.js
function Device(model) {
this.model = model;
}
 
const d1 = new Device("Phone");
 
// Prototype mutated AFTER d1 was already constructed!
Device.prototype.reboot = function() {
return `Rebooting ${this.model}`;
};
 
console.log(d1.reboot());

Think through the execution steps before checking

3

Overwriting Child.prototype With Object Literal

Hard
jsconsole-test.js
function Parent() {}
function Child() {}
 
Child.prototype = {
sayHi() { return "Hi!"; }
};
 
const c = new Child();
console.log(c instanceof Child);
console.log(c.constructor === Child);
console.log(c.constructor === Object);

Think through the execution steps before checking

4

Shared Mutable Reference on Prototype

Medium
jsconsole-test.js
function Team(name) {
this.name = name;
}
 
// ⚠️ Storing a mutable Array directly on the prototype!
Team.prototype.members = [];
 
const frontend = new Team("Frontend");
const backend = new Team("Backend");
 
frontend.members.push("Dev Kant");
 
console.log(backend.members);
console.log(frontend.members === backend.members);

Think through the execution steps before checking

5

`super` Inside Plain Object Literals

Hard
jsconsole-test.js
const base = {
name: "Base",
greet() { return `Hello from ${this.name}`; }
};
 
const derived = {
name: "Derived",
greet() {
return super.greet() + " & Sub";
}
};
 
Object.setPrototypeOf(derived, base);
console.log(derived.greet());

Think through the execution steps before checking

6

Class Getter vs Own Property Collision

Tricky
jsconsole-test.js
class Counter {
get count() {
return 10;
}
}
 
const c = new Counter();
// Assigning an own property with the same name as class getter:
c.count = 20;
 
console.log(c.count);

Think through the execution steps before checking

8. Implement from Scratch: Coding Round Polyfills

Writing your own `new`, `Object.create`, `instanceof`, `call`/`apply`, EventEmitter, data structures, and design patterns.

In technical screening rounds for Tier-1 technology companies, you will often be asked to write polyfills without using built-in shortcuts:

Polyfill: `Object.create`

jsmyObjectCreate.js
// 🛠️ 1. Object.create Polyfill
function myObjectCreate(proto, propertiesObject) {
if (typeof proto !== 'object' && typeof proto !== 'function') {
throw new TypeError('Object prototype may only be an Object or null: ' + proto);
}
 
// Temporary constructor to establish prototype link without executing original constructor
function F() {}
F.prototype = proto;
const obj = new F();
 
// If proto was null, break the default Object.prototype link
if (proto === null) {
obj.__proto__ = null;
}
 
if (propertiesObject !== undefined) {
Object.defineProperties(obj, propertiesObject);
}
 
return obj;
}

Polyfill: `instanceof` Operator

jsmyInstanceOf.js
// 🛠️ 2. instanceof Polyfill
function myInstanceOf(instance, Constructor) {
// Primitives are never instances
if (instance === null || (typeof instance !== 'object' && typeof instance !== 'function')) {
return false;
}
if (typeof Constructor !== 'function') {
throw new TypeError("Right-hand side of 'instanceof' is not callable");
}
 
const targetPrototype = Constructor.prototype;
let currentProto = Object.getPrototypeOf(instance);
 
// Traverse the prototype chain until matching targetPrototype or hitting null
while (currentProto !== null) {
if (currentProto === targetPrototype) {
return true;
}
currentProto = Object.getPrototypeOf(currentProto);
}
 
return false;
}
 
// Verification:
console.log(myInstanceOf([], Array)); // true
console.log(myInstanceOf([], Object)); // true
console.log(myInstanceOf("string", String)); // false (primitive)

Polyfill: `Function.prototype.call` and `apply`

jscall-apply-polyfills.js
// 🛠️ 3. Function.prototype.call & apply Polyfills
 
Function.prototype.myCall = function(context, ...args) {
// Fallback to globalThis if null/undefined passed
context = (context === null || context === undefined) ? globalThis : Object(context);
// Create unique Symbol key to prevent property collision
const fnSymbol = Symbol("tempFn");
context[fnSymbol] = this; // 'this' is the function to execute
 
const result = context[fnSymbol](...args);
delete context[fnSymbol]; // Cleanup
return result;
};
 
Function.prototype.myApply = function(context, argsArray = []) {
context = (context === null || context === undefined) ? globalThis : Object(context);
const fnSymbol = Symbol("tempFn");
context[fnSymbol] = this;
 
const result = Array.isArray(argsArray)
? context[fnSymbol](...argsArray)
: context[fnSymbol]();
 
delete context[fnSymbol];
return result;
};

Data Structure: Custom `EventEmitter` Class

jsEventEmitter.js
// 📡 4. EventEmitter Class (Node.js style event bus)
class EventEmitter {
constructor() {
this.events = Object.create(null); // Dictionary without prototype pollution
}
 
on(eventName, listener) {
if (!this.events[eventName]) {
this.events[eventName] = [];
}
this.events[eventName].push(listener);
return this; // Enable method chaining
}
 
emit(eventName, ...args) {
const listeners = this.events[eventName];
if (!listeners || listeners.length === 0) return false;
// Clone array to prevent issues if a listener unsubscribes during emit
[...listeners].forEach(fn => fn.apply(this, args));
return true;
}
 
off(eventName, listenerToRemove) {
if (!this.events[eventName]) return this;
this.events[eventName] = this.events[eventName].filter(fn => fn !== listenerToRemove);
return this;
}
 
once(eventName, listener) {
const wrapper = (...args) => {
this.off(eventName, wrapper);
listener.apply(this, args);
};
return this.on(eventName, wrapper);
}
}
 
// Usage test:
const bus = new EventEmitter();
bus.once("greet", (name) => console.log(`Welcome ${name}`));
bus.emit("greet", "Dev Kant"); // "Welcome Dev Kant"
bus.emit("greet", "Dev Kant"); // Silent (did not fire twice)

Data Structures: `Stack`, `Queue`, and `LinkedList`

jsdata-structures.js
// 📚 5. Production Stack, Queue & LinkedList as Classes
 
// Stack (LIFO)
class Stack {
#items = [];
push(element) { this.#items.push(element); }
pop() { return this.#items.pop(); }
peek() { return this.#items[this.#items.length - 1]; }
get size() { return this.#items.length; }
}
 
// Queue (FIFO) - Optimal O(1) via head pointer
class Queue {
#items = {};
#head = 0;
#tail = 0;
 
enqueue(item) {
this.#items[this.#tail++] = item;
}
dequeue() {
if (this.size === 0) return undefined;
const item = this.#items[this.#head];
delete this.#items[this.#head++];
return item;
}
get size() { return this.#tail - this.#head; }
}
 
// Singly Linked List
class Node {
constructor(value, next = null) {
this.value = value;
this.next = next;
}
}
 
class LinkedList {
constructor() {
this.head = null;
this.size = 0;
}
 
append(value) {
const node = new Node(value);
if (!this.head) {
this.head = node;
} else {
let current = this.head;
while (current.next) {
current = current.next;
}
current.next = node;
}
this.size++;
}
}

Essential OOP Design Patterns (Singleton, Strategy, Observer)

jsoop-design-patterns.js
// 🏗️ 6. Design Patterns in JavaScript OOP
 
// Pattern A: Thread-Safe Class Singleton
class DatabasePool {
static #instance = null;
#connectionId;
 
constructor() {
if (DatabasePool.#instance) {
return DatabasePool.#instance;
}
this.#connectionId = Math.random().toString(36).substring(7);
DatabasePool.#instance = this;
}
 
getConnectionId() {
return this.#connectionId;
}
}
 
const pool1 = new DatabasePool();
const pool2 = new DatabasePool();
console.log(pool1 === pool2); // true!
 
// Pattern B: Strategy Pattern (Interchangeable algorithms)
class TaxCalculator {
setStrategy(strategy) {
this.strategy = strategy;
}
calculate(amount) {
return this.strategy.calculate(amount);
}
}
 
const usTaxStrategy = { calculate: amt => amt * 0.08 };
const euTaxStrategy = { calculate: amt => amt * 0.20 };
 
const taxCalc = new TaxCalculator();
taxCalc.setStrategy(euTaxStrategy);
console.log(taxCalc.calculate(100)); // 20
 
// Pattern C: Observer Pattern
class Subject {
#observers = new Set();
subscribe(observer) { this.#observers.add(observer); }
unsubscribe(observer) { this.#observers.delete(observer); }
notify(data) { this.#observers.forEach(obs => obs.update(data)); }
}

Deep Clone Preserving Prototypes and Circular References

Standard structuredClone() strips away custom prototype chains and class methods. Here is how to clone deeply while preserving the prototype hierarchy and avoiding cyclic stack overflows:

jsdeepCloneWithProto.js
// 🧬 7. Deep Clone Preserving Prototypes & Cyclic Graphs
function deepCloneWithProto(target, hash = new WeakMap()) {
// Primitives & functions returned directly
if (target === null || typeof target !== 'object') {
return target;
}
 
// Handle Dates & RegExps
if (target instanceof Date) return new Date(target);
if (target instanceof RegExp) return new RegExp(target);
 
// Prevent infinite loops on circular references
if (hash.has(target)) {
return hash.get(target);
}
 
// Preserve the exact prototype of the original object!
const clone = Object.create(Object.getPrototypeOf(target));
hash.set(target, clone);
 
// Clone own property descriptors (preserves non-enumerable, getters/setters, symbols)
Reflect.ownKeys(target).forEach(key => {
const desc = Object.getOwnPropertyDescriptor(target, key);
if (desc.value && typeof desc.value === 'object') {
desc.value = deepCloneWithProto(desc.value, hash);
}
Object.defineProperty(clone, key, desc);
});
 
return clone;
}

9. SOLID Principles & Enterprise Design in JavaScript

Applying SOLID in dynamic environments, constructor dependency injection, Controller/Service/Repository layering, and when classes are the wrong tool.

How SOLID Principles Translate to JavaScript

SOLID is not exclusive to compiled languages like Java or C#. In modern full-stack Node.js and TypeScript architectures, SOLID provides the guardrails that prevent microservices and enterprise backends from degrading into unmaintainable spaghetti code:

jssolid-principles.js
// 🏛️ SOLID Principles in Modern JavaScript Architecture
 
// 1. Single Responsibility Principle (SRP)
// ❌ Bad: User class manages state, database persistence, AND email notifications
// ✅ Good: Split into focused cohesive classes
class User {
constructor(id, email) { this.id = id; this.email = email; }
}
class UserRepository {
async save(user) { /* DB queries */ }
}
class EmailService {
async sendWelcomeEmail(user) { /* Email logic */ }
}
 
// 2. Open/Closed Principle (OCP): Open for extension, closed for modification
// ❌ Bad: Giant switch statement modified whenever a new payment gateway is added
// ✅ Good: Strategy pattern where new processors extend an abstract contract
class PaymentProcessor {
process(amount) { throw new Error("Must implement process()"); }
}
class StripeProcessor extends PaymentProcessor {
process(amount) { return `Paid $${amount} via Stripe`; }
}
class PayPalProcessor extends PaymentProcessor {
process(amount) { return `Paid $${amount} via PayPal`; }
}
class CheckoutService {
constructor(processor) { this.processor = processor; }
checkout(amount) { return this.processor.process(amount); }
}
 
// 3. Liskov Substitution Principle (LSP): Subclasses must be substitutable for base classes
// ❌ Bad: Subclass breaking parent contract
class Bird { fly() { return "Flying"; } }
class Penguin extends Bird {
fly() { throw new Error("Penguins cannot fly!"); } // Violates LSP!
}
// ✅ Good: Model behaviors accurately
class FlyingBird { fly() { return "Flying"; } }
class SwimmingBird { swim() { return "Swimming"; } }
 
// 4. Interface Segregation Principle (ISP)
// In dynamically typed JS, do not force client code to depend on massive fat objects.
// Keep duck-type interfaces lean.
 
// 5. Dependency Inversion Principle (DIP)
// High-level modules should not depend on low-level modules; both depend on abstractions.

Constructor Dependency Injection in Node.js

Hard-coding singleton imports directly inside service files (import db from './db') couples your business logic tightly to specific infrastructure, making unit testing almost impossible without brittle module mocks:

jsdependency-injection.js
// 💉 Dependency Injection (DI) in Node.js Services
 
// Database abstraction contract
class IUserRepository {
async findById(id) { throw new Error("Not implemented"); }
}
 
// Concrete MongoDB implementation
class MongoUserRepository extends IUserRepository {
async findById(id) {
return { id, name: "Dev Kant", db: "MongoDB" };
}
}
 
// High-level service accepts dependency via constructor injection
class UserService {
constructor(userRepository, logger) {
this.userRepo = userRepository;
this.logger = logger;
}
 
async getUserProfile(id) {
this.logger.info(`Fetching profile for ${id}`);
const user = await this.userRepo.findById(id);
if (!user) throw new Error("User not found");
return user;
}
}
 
// Test / Mock injection is trivial:
const mockRepo = { findById: async () => ({ id: "mock_1", name: "Tester" }) };
const mockLogger = { info: () => {} };
const testService = new UserService(mockRepo, mockLogger);

Controller / Service / Repository Layering

In modern Express, Fastify, or NestJS applications, the standard three-tier architecture separates concerns cleanly:

jslayered-architecture.js
// 🏢 Layered Architecture: Controller -> Service -> Repository
 
// 1. Controller Layer: Handles HTTP requests, input validation, status codes
class UserController {
constructor(userService) {
this.userService = userService;
}
 
async getProfile(req, res, next) {
try {
const { id } = req.params;
const profile = await this.userService.getProfile(id);
return res.status(200).json({ success: true, data: profile });
} catch (err) {
next(err);
}
}
}
 
// 2. Service Layer: Contains business logic, transactions, authorization
class UserService {
constructor(userRepo, notificationService) {
this.userRepo = userRepo;
this.notificationService = notificationService;
}
 
async getProfile(id) {
const user = await this.userRepo.findById(id);
if (!user) {
const error = new Error("User not found");
error.statusCode = 404;
throw error;
}
return user;
}
}
 
// 3. Repository Layer: Raw SQL / ORM / Database access
class PostgresUserRepository {
constructor(dbPool) {
this.db = dbPool;
}
 
async findById(id) {
const query = "SELECT id, email, created_at FROM users WHERE id = $1";
const { rows } = await this.db.query(query, [id]);
return rows[0] || null;
}
}

When Classes Are the WRONG Tool in JavaScript

Top interviewers respect candidates who know when not to use classes. JavaScript is a multi-paradigm language, and forcing classes everywhere is an anti-pattern:

When to Use Classes

  • • Stateful domain entities (e.g. Order, UserSession)
  • • Data structures with high instantiation volume (e.g. LinkedList, Tree)
  • • Custom Error hierarchies (class NotFoundError extends Error)
  • • Infrastructure services injected via DI (PaymentService)

When Functions / Modules are Better

  • • Pure utility calculations (use exported functions: formatDate, calcTax)
  • • Data transformation pipelines (use map, filter, reduce)
  • • React components (modern React standard is Hooks + Function Components)
  • • Stateless middleware functions in Express

10. Node.js Backend OOP: Production Patterns

Operational error hierarchies, extending `EventEmitter`, Mongoose active record architecture, and Node module caching singletons.

Custom Error Hierarchies with `Error.captureStackTrace`

In production Node.js architectures, throwing generic string errors or plain new Error("failed") is a major liability. Senior backend engineers construct structured domain error hierarchies:

jsAppError.js
// 🛑 Production-Grade Custom Error Hierarchy for Node.js Backends
 
class AppError extends Error {
constructor(message, statusCode = 500, isOperational = true) {
super(message);
this.name = this.constructor.name;
this.statusCode = statusCode;
this.status = `${statusCode}`.startsWith('4') ? 'fail' : 'error';
// Distinguishes trusted operational errors (bad user input) from programmer bugs
this.isOperational = isOperational;
 
// V8 Engine optimization: excludes this constructor function call from the stack trace
if (Error.captureStackTrace) {
Error.captureStackTrace(this, this.constructor);
}
}
}
 
class BadRequestError extends AppError {
constructor(message = "Bad request parameters") {
super(message, 400);
}
}
 
class UnauthorizedError extends AppError {
constructor(message = "Authentication required") {
super(message, 401);
}
}
 
class NotFoundError extends AppError {
constructor(resource = "Resource") {
super(`${resource} not found`, 404);
}
}
 
// Global Express Error Middleware cleanly checks instanceof AppError:
function errorHandler(err, req, res, next) {
if (err instanceof AppError && err.isOperational) {
return res.status(err.statusCode).json({
status: err.status,
message: err.message,
});
}
 
// Untrusted programmer error (crash gracefully or report to Sentry/Datadog)
console.error("CRITICAL UNHANDLED ERROR 💥", err);
return res.status(500).json({
status: 'error',
message: 'Internal server error occurred',
});
}

Subclassing `EventEmitter` in Real-Time Systems

Node.js is built on event-driven architecture. Creating custom event producers by extending EventEmitter is the standard pattern for background workers, stream aggregators, and WebSocket brokers:

jsJobQueueWorker.js
// 📡 Extending EventEmitter in Real-Time Backend Services
import { EventEmitter } from 'node:events';
 
class JobQueueWorker extends EventEmitter {
#queue = [];
#isRunning = false;
 
constructor(workerName) {
super();
this.workerName = workerName;
}
 
enqueue(job) {
this.#queue.push(job);
this.emit('job:added', { worker: this.workerName, jobId: job.id });
if (!this.#isRunning) {
this.#processNext();
}
}
 
async #processNext() {
if (this.#queue.length === 0) {
this.#isRunning = false;
this.emit('drain');
return;
}
 
this.#isRunning = true;
const job = this.#queue.shift();
 
try {
this.emit('job:started', job.id);
await job.execute();
this.emit('job:completed', job.id);
} catch (error) {
// ⚠️ In Node.js EventEmitter, if an 'error' event is emitted with NO listeners,
// Node will crash the process! Always handle error listeners.
this.emit('error', error);
} finally {
this.#processNext();
}
}
}
 
// Client usage
const worker = new JobQueueWorker("VideoProcessor-1");
worker.on('job:completed', (id) => console.log(`Job ${id} completed!`));
worker.on('error', (err) => console.error(`Worker error caught safely: ${err.message}`));

How Mongoose & Express Relate to OOP

Even though Express handlers look like functional middleware pipelines, Node web frameworks rely on OOP underneath:

jsmongoose-express-oop.js
// 🍃 How Mongoose Models & Express Middleware Relate to OOP
 
// 1. Mongoose Schemas use the Prototype Pattern + Active Record:
// schema.methods -> instance methods attached to Document.prototype!
userSchema.methods.generateAuthToken = function() {
// 'this' refers to the specific MongoDB document instance
return jwt.sign({ id: this._id }, process.env.JWT_SECRET);
};
 
// schema.statics -> static methods attached directly to Model constructor!
userSchema.statics.findByEmail = function(email) {
// 'this' refers to the User Model constructor
return this.findOne({ email });
};
 
// 2. Express Request Context & OOP:
// Express middleware operates procedurally, but req and res inherit prototypes:
// req.__proto__ -> express.request -> http.IncomingMessage.prototype
// Extending express prototypes adds global capabilities across all handlers:
import express from 'express';
express.request.getValidatedUser = function() {
return this.user ?? null;
};

Module-Level Singletons vs Class Singletons

In Node.js, you often don't need a classic Java-style singleton class because Node's module loader caches modules in memory after the first import:

jsmodule-singleton.js
// 🔒 Module-Level Singletons vs Class Singletons in Node.js
 
// File: database.js
// Node.js caches the evaluated exports of every module in require.cache / module map!
class DatabaseClient {
constructor() {
this.connected = true;
this.timestamp = Date.now();
}
}
 
// Approach 1: Exporting an instantiated instance creates an automatic Singleton!
export const dbInstance = new DatabaseClient();
 
// Approach 2: Explicit Class with static instance check
// Useful if you need lifecycle control, parameter validation, or multi-tenant instances:
export class TenantDatabaseManager {
static #instances = new Map();
 
static getInstance(tenantId) {
if (!this.#instances.has(tenantId)) {
this.#instances.set(tenantId, new TenantDatabaseManager(tenantId));
}
return this.#instances.get(tenantId);
}
}

Conclusion & Interview Readiness Checklist

Quick reference summary matrix and the 10-point mastery checklist before your technical round.

Mastering Object-Oriented Programming in JavaScript is not about memorizing syntax; it is about understanding the underlying engine mechanics: how memory is allocated, how property lookup traverses the prototype chain, how call-sites determine lexical versus dynamic contexts, and how clean software architecture is maintained in real-world systems.

The 10-Point JavaScript OOP Interview Checklist
1I can explain the difference between [[Prototype]], Function.prototype, and __proto__ without hesitation.
2I know the exact 4-step algorithm executed by the `new` keyword and can write its polyfill from memory.
3I understand why methods belong on the prototype rather than inside constructors (heap allocation profiling).
4I can recite the 4 `this` binding rules in exact order of precedence (new > explicit > implicit > default).
5I know why `super()` must precede `this` in derived class constructors (engine [[ThisBindingStatus]]).
6I can predict detached callback `this` loss and explain arrow function memory costs.
7I can safely check object properties using ES2022 `Object.hasOwn` instead of `hasOwnProperty`.
8I know how to implement true privacy using ES2022 `#private` fields and understand brand checking.
9I can write a custom `bind` polyfill supporting currying and `new` operator overrides.
10I can architect Node.js backend services using Dependency Injection, SOLID principles, and custom AppError hierarchies.

Final Advice for Senior Technical Rounds

When asked an OOP question in an interview, resist jumping straight into boilerplate code. First articulate the mental model: explain whether you are optimizing for prototype sharing or closures, call out edge cases like null prototypes, and demonstrate architectural awareness by discussing composition and dependency injection.

Interviewers hire engineers who understand why systems behave the way they do, not just those who can regurgitate syntax.

Frequently Asked Interview Questions

Quick, high-impact answers optimized for senior and staff-level technical screens.

Java and C++ use class-based inheritance where classes are static, compile-time blueprints from which instances are minted. JavaScript uses prototypal delegation: objects inherit directly from other objects via an internal [[Prototype]] link. ES6 classes in JavaScript are syntactic sugar over this dynamic prototype delegation model.

Enjoyed this article?Leave a reaction

Tap multiple times to show love!

Featured Laptop DealLive Deal
1 / 7
Best Business & After-Sales (Under ₹50K)HP 250R G10 Business Laptop (Intel 14th Gen Core 3-100U / 512GB NVMe SSD / 15.6" FHD IPS)
4.4/ 5.0 (Amazon Verified)

HP 250R G10 Business Laptop (Intel 14th Gen Core 3-100U / 512GB NVMe SSD / 15.6" FHD IPS)

✓Intel Core 3-100U 14th Gen Processor (6 Cores, 8 Threads up to 4.7GHz)
✓Expandable High-Speed DDR4 RAM + 512GB PCIe NVMe SSD
Amazon Deal₹49,189
Check on AmazonPrime Eligible • Bank Offers Available

Tags

#JavaScript#OOP#Prototypes#ES6 Classes#this Keyword#Interview Prep#Design Patterns#SOLID#Node.js
Dev Kant Kumar

Dev Kant Kumar

Author

Full Stack Developer passionate about crafting high-performance user experiences. I write about Agentic AI, React, and the future of web development.

💬 Discussion

Featured Laptop DealLive Deal
1 / 7
Best Business & After-Sales (Under ₹50K)HP 250R G10 Business Laptop (Intel 14th Gen Core 3-100U / 512GB NVMe SSD / 15.6" FHD IPS)
4.4/ 5.0 (Amazon Verified)

HP 250R G10 Business Laptop (Intel 14th Gen Core 3-100U / 512GB NVMe SSD / 15.6" FHD IPS)

✓Intel Core 3-100U 14th Gen Processor (6 Cores, 8 Threads up to 4.7GHz)
✓Expandable High-Speed DDR4 RAM + 512GB PCIe NVMe SSD
Amazon Deal₹49,189
Check on AmazonPrime Eligible • Bank Offers Available