Object-Oriented JavaScript
Learn class structures, constructor methods, encapsulation, encapsulation fields, static methods, and prototype-based programming.
Downloaded from PrepIQ (https://prepiq.online)
Q1. What are the four main principles of OOP and how does JavaScript support them?
| Principle | Meaning | How JavaScript supports it |
|---|---|---|
| Encapsulation | Bundling data and methods, hiding internal details | Private fields (#field), closures |
| Abstraction | Exposing only relevant details, hiding complexity | Public methods hiding private implementation |
| Inheritance | Reusing behavior from a parent class | extends, prototype chain |
| Polymorphism | Different classes respond differently to the same method call | Method overriding |
// ENCAPSULATION - private fields hide internal state
class BankAccount {
#balance; // truly private, accessible only inside this class
constructor(initialBalance) {
this.#balance = initialBalance;
}
deposit(amount) {
this.#balance += amount;
}
getBalance() {
return this.#balance; // controlled access
}
}
const acc = new BankAccount(100);
// console.log(acc.#balance); // SyntaxError - truly inaccessible from outside
console.log(acc.getBalance()); // 100
// INHERITANCE
class Shape {
constructor(name) { this.name = name; }
area() { return 0; } // base implementation
}
// POLYMORPHISM - each subclass overrides area() differently
class Circle extends Shape {
constructor(radius) { super('Circle'); this.radius = radius; }
area() { return Math.PI * this.radius ** 2; }
}
class Square extends Shape {
constructor(side) { super('Square'); this.side = side; }
area() { return this.side ** 2; }
}
const shapes = [new Circle(5), new Square(4)];
shapes.forEach(shape => {
console.log(`${shape.name} area: ${shape.area()}`); // same method call, different behavior
});
// ABSTRACTION - complex logic hidden behind a simple public method
class PaymentProcessor {
processPayment(amount) {
this.#validateAmount(amount);
this.#chargeCard(amount);
this.#sendReceipt();
}
#validateAmount(amount) { /* internal detail */ }
#chargeCard(amount) { /* internal detail */ }
#sendReceipt() { /* internal detail */ }
}
Q2. How do private class fields (#field) work in JavaScript?
Private fields, prefixed with #, are truly inaccessible from outside the class - unlike the old convention of prefixing with an underscore, which was only a naming convention, not real privacy.
class Counter {
#count = 0; // private field, initialized directly
#step; // private field, no default
constructor(step = 1) {
this.#step = step;
}
increment() {
this.#count += this.#step;
return this.#count;
}
get value() {
return this.#count;
}
}
const counter = new Counter(5);
console.log(counter.increment()); // 5
console.log(counter.value); // 5
// Truly inaccessible from outside
console.log(counter.count); // undefined - not #count, just a regular missing property
// console.log(counter.#count); // SyntaxError - private fields can't even be referenced outside the class
// Compare to old convention (NOT truly private, just a naming signal)
class OldStyleCounter {
constructor() {
this._count = 0; // underscore convention - developers SHOULD not touch it, but CAN
}
}
const old = new OldStyleCounter();
old._count = 999; // works fine - no real protection
// Private methods also exist
class Calculator {
#history = [];
add(a, b) {
const result = a + b;
this.#logOperation('add', result); // calling a private method
return result;
}
#logOperation(type, result) { // private method - # prefix
this.#history.push({ type, result });
}
getHistory() {
return [...this.#history]; // controlled read-only access
}
}
// Static private fields for class-level private state
class IdGenerator {
static #nextId = 1;
static generate() {
return IdGenerator.#nextId++;
}
}
console.log(IdGenerator.generate()); // 1
console.log(IdGenerator.generate()); // 2
Q3. How do getters and setters work in JavaScript classes?
Getters and setters let you define methods that are accessed like properties, enabling computed properties and validation logic that runs transparently on read/write.
class Temperature {
#celsius;
constructor(celsius) {
this.#celsius = celsius;
}
// Getter - accessed like a property, no parentheses needed
get fahrenheit() {
return (this.#celsius * 9) / 5 + 32;
}
// Setter - allows writing through validation logic
set fahrenheit(value) {
this.#celsius = ((value - 32) * 5) / 9;
}
get celsius() {
return this.#celsius;
}
set celsius(value) {
if (value < -273.15) {
throw new Error('Temperature below absolute zero is impossible');
}
this.#celsius = value;
}
}
const temp = new Temperature(25);
console.log(temp.fahrenheit); // 77 - called like a property, no ()
temp.fahrenheit = 100; // triggers the setter, converts and stores as celsius
console.log(temp.celsius); // 37.77...
try {
temp.celsius = -300; // triggers validation in the setter
} catch (error) {
console.log(error.message); // Temperature below absolute zero is impossible
}
// Getters/setters are also useful for computed properties
class Rectangle {
constructor(width, height) {
this.width = width;
this.height = height;
}
get area() {
return this.width * this.height; // always up to date, computed on access
}
}
const rect = new Rectangle(5, 10);
console.log(rect.area); // 50
rect.width = 20;
console.log(rect.area); // 200 - recalculated automatically
Q4. What is method overriding and how does super work with it?
Method overriding lets a subclass provide its own implementation of a method inherited from a parent class. The super keyword allows calling the parent's version from within the override.
class Employee {
constructor(name, salary) {
this.name = name;
this.salary = salary;
}
getDetails() {
return `${this.name}: $${this.salary}`;
}
calculateBonus() {
return this.salary * 0.1; // default 10% bonus
}
}
class Manager extends Employee {
constructor(name, salary, teamSize) {
super(name, salary);
this.teamSize = teamSize;
}
// OVERRIDING getDetails - completely replaces parent behavior
getDetails() {
return `${super.getDetails()}, manages ${this.teamSize} people`;
// super.getDetails() calls the PARENT's version, then extends it
}
// OVERRIDING calculateBonus with different logic entirely
calculateBonus() {
const baseBonus = super.calculateBonus(); // reuse parent's calculation
const managerBonus = this.teamSize * 100; // add manager-specific bonus
return baseBonus + managerBonus;
}
}
const emp = new Employee('John', 50000);
console.log(emp.getDetails()); // John: $50000
console.log(emp.calculateBonus()); // 5000
const mgr = new Manager('Jane', 80000, 5);
console.log(mgr.getDetails()); // Jane: $80000, manages 5 people
console.log(mgr.calculateBonus()); // 8000 + 500 = 8500
// Constructor chaining - super() MUST be called before using 'this' in a subclass
class Base {
constructor() { this.type = 'base'; }
}
class Derived extends Base {
constructor() {
// this.type = 'derived'; // ReferenceError if called before super()
super(); // must come first
this.type = 'derived'; // now safe to use 'this'
}
}
Q5. What is composition and how does it compare to inheritance in JavaScript?
Composition builds objects by combining smaller, focused pieces of functionality rather than through a class hierarchy. It's often favored over deep inheritance chains because it's more flexible.
| Aspect | Inheritance | Composition |
|---|---|---|
| Relationship | 'is-a' (Dog is an Animal) | 'has-a' (Car has an Engine) |
| Flexibility | Rigid, fixed at class definition | Flexible, can mix and match behaviors |
| Coupling | Tightly coupled to parent class | Loosely coupled, independent pieces |
// INHERITANCE approach - can lead to rigid hierarchies
class Bird {
fly() { return 'Flying'; }
}
class Penguin extends Bird {
// Problem: penguins can't actually fly, but they inherit fly() anyway
fly() { throw new Error('Penguins cannot fly!'); } // awkward override
}
// COMPOSITION approach - combine only the behaviors that apply
const canFly = (state) => ({
fly: () => `${state.name} is flying`
});
const canSwim = (state) => ({
swim: () => `${state.name} is swimming`
});
const canWalk = (state) => ({
walk: () => `${state.name} is walking`
});
function createBird(name, abilities) {
const state = { name };
return Object.assign({}, state, ...abilities.map(ability => ability(state)));
}
const eagle = createBird('Eagle', [canFly, canWalk]);
console.log(eagle.fly()); // Eagle is flying
console.log(eagle.walk()); // Eagle is walking
const penguin = createBird('Penguin', [canSwim, canWalk]); // no canFly - accurate!
console.log(penguin.swim()); // Penguin is swimming
// penguin.fly(); // TypeError - fly is not a function, correctly doesn't exist
// Composition using classes and dependency injection
class Engine {
start() { return 'Engine starting'; }
}
class Car {
constructor(engine) {
this.engine = engine; // 'has-a' relationship, composed not inherited
}
start() {
return this.engine.start();
}
}
const car = new Car(new Engine());
console.log(car.start()); // Engine starting
// The engine can easily be swapped for a different implementation (e.g. ElectricEngine)
Q6. How does the instanceof operator work and what are its limitations?
instanceof checks whether a constructor's prototype exists anywhere in an object's prototype chain. It works well for standard scenarios but has edge cases worth knowing.
class Animal {}
class Dog extends Animal {}
const rex = new Dog();
console.log(rex instanceof Dog); // true
console.log(rex instanceof Animal); // true - checks the whole chain
console.log(rex instanceof Object); // true - everything inherits from Object
// LIMITATION 1: doesn't work reliably across different execution contexts (iframes)
// An array created in one iframe won't be instanceof Array in another iframe
// because each context has its own separate Array constructor
// LIMITATION 2: primitives are NOT instances of their wrapper classes
console.log(5 instanceof Number); // false - primitive number
console.log(new Number(5) instanceof Number); // true - boxed Number object
typeof 5; // 'number'
typeof new Number(5); // 'object' - different behavior entirely
// LIMITATION 3: instanceof can be manipulated via Symbol.hasInstance
class EvenNumber {
static [Symbol.hasInstance](num) {
return Number.isInteger(num) && num % 2 === 0;
}
}
console.log(4 instanceof EvenNumber); // true - custom instanceof logic!
console.log(5 instanceof EvenNumber); // false
// LIMITATION 4: manually reassigning prototype breaks instanceof checks
function Foo() {}
const foo = new Foo();
console.log(foo instanceof Foo); // true
Foo.prototype = {}; // reassigning after instance creation
console.log(foo instanceof Foo); // false - foo's prototype link is unchanged, but Foo.prototype moved
// Alternative: for checking plain object 'type' by structure, consider duck typing
// or Object.prototype.toString.call() for primitives/built-ins
console.log(Object.prototype.toString.call([])); // '[object Array]'
console.log(Object.prototype.toString.call(null)); // '[object Null]'
Q7. How do abstract-like classes and interfaces work in JavaScript, since it doesn't natively support them?
JavaScript has no built-in 'abstract class' or 'interface' keywords, but the same design intentions can be enforced manually using constructor checks and method existence checks.
// Simulating an abstract class - prevents direct instantiation
class Shape {
constructor() {
if (new.target === Shape) {
// new.target refers to the constructor actually called with 'new'
throw new Error('Shape is abstract and cannot be instantiated directly');
}
}
area() {
throw new Error('area() must be implemented by subclass'); // enforced 'abstract method'
}
}
class Circle extends Shape {
constructor(radius) {
super();
this.radius = radius;
}
area() { // required override
return Math.PI * this.radius ** 2;
}
}
// new Shape(); // Error: Shape is abstract and cannot be instantiated directly
const circle = new Circle(5);
console.log(circle.area()); // 78.53...
class Triangle extends Shape {
// Forgot to implement area()
}
const triangle = new Triangle();
// triangle.area(); // throws 'area() must be implemented by subclass'
// Simulating an interface via a checking function (duck typing)
function implementsInterface(obj, methods) {
return methods.every(method => typeof obj[method] === 'function');
}
class FileLogger {
log(msg) { console.log('File:', msg); }
error(msg) { console.log('File error:', msg); }
}
const logger = new FileLogger();
console.log(implementsInterface(logger, ['log', 'error'])); // true
// TypeScript's interfaces solve this problem at COMPILE time instead;
// in plain JS, these runtime checks are the closest equivalent
Q8. What is the difference between class fields and constructor-assigned properties?
Class fields (introduced in ES2022) let you declare instance properties directly in the class body, offering a more declarative alternative to assigning them inside the constructor.
// Constructor-assigned properties (traditional approach)
class UserOld {
constructor(name) {
this.name = name;
this.createdAt = new Date();
this.isActive = true;
}
}
// Class fields (modern, declarative approach)
class User {
createdAt = new Date(); // evaluated for EACH new instance
isActive = true; // default value, doesn't need constructor
name; // declared without initial value
constructor(name) {
this.name = name; // still assigned in constructor since it varies per call
}
}
const u1 = new User('John');
console.log(u1.isActive); // true - from class field default
// Class fields are especially useful for arrow function methods (bound 'this')
class Button {
label = 'Click me';
// Class field with arrow function - 'this' is bound to instance automatically
handleClick = () => {
console.log(`${this.label} was clicked`);
};
}
const btn = new Button();
const handler = btn.handleClick;
handler(); // works correctly - 'this' remains bound, unlike a regular method
// Static class fields
class Config {
static version = '1.0.0'; // class-level property, not per-instance
static #instances = 0; // private static field
constructor() {
Config.#instances++;
}
static getInstanceCount() {
return Config.#instances;
}
}
console.log(Config.version); // 1.0.0
new Config(); new Config();
console.log(Config.getInstanceCount()); // 2
Q9. How do you implement the Singleton pattern in JavaScript?
The Singleton pattern ensures a class has only one instance and provides a global point of access to it - useful for things like a single database connection or a shared configuration object.
// Singleton using a class with a static instance check
class Database {
static #instance;
constructor() {
if (Database.#instance) {
return Database.#instance; // return the existing instance instead of creating new
}
this.connection = 'Connected to DB';
Database.#instance = this;
}
query(sql) {
return `Running: ${sql}`;
}
}
const db1 = new Database();
const db2 = new Database();
console.log(db1 === db2); // true - same instance, second 'new' returned the first
// Singleton using a static getInstance() method (more explicit, common pattern)
class Logger {
static #instance;
static getInstance() {
if (!Logger.#instance) {
Logger.#instance = new Logger();
}
return Logger.#instance;
}
log(message) {
console.log(`[LOG]: ${message}`);
}
}
const logger1 = Logger.getInstance();
const logger2 = Logger.getInstance();
console.log(logger1 === logger2); // true
// Simplest Singleton using a module - the module system itself provides the guarantee
// config.js
const config = {
apiUrl: 'https://api.example.com',
timeout: 5000
};
export default config;
// Every file that imports config.js gets the SAME object reference
// because ES modules are cached/singleton by nature
// Caution: singletons introduce global state and hidden coupling,
// making testing harder - use sparingly and consider dependency injection instead
Q10. How do you implement encapsulation in JavaScript before private fields existed (using closures)?
Before the # syntax was standardized, closures were the primary way to achieve true data privacy - variables inside a constructor function that are never attached to 'this' are inaccessible from outside.
// Closure-based encapsulation - the classic pre-# approach
function BankAccount(initialBalance) {
let balance = initialBalance; // truly private - not attached to 'this'
const transactionHistory = [];
this.deposit = function(amount) {
balance += amount;
transactionHistory.push({ type: 'deposit', amount });
return balance;
};
this.withdraw = function(amount) {
if (amount > balance) {
throw new Error('Insufficient funds');
}
balance -= amount;
transactionHistory.push({ type: 'withdraw', amount });
return balance;
};
this.getBalance = function() {
return balance; // controlled read access
};
this.getHistory = function() {
return [...transactionHistory]; // return a copy, prevent external mutation
};
}
const account = new BankAccount(100);
account.deposit(50);
console.log(account.getBalance()); // 150
console.log(account.balance); // undefined - truly inaccessible, not just convention
// Comparison with the modern private field syntax
class ModernBankAccount {
#balance;
#transactionHistory = [];
constructor(initialBalance) {
this.#balance = initialBalance;
}
deposit(amount) {
this.#balance += amount;
this.#transactionHistory.push({ type: 'deposit', amount });
return this.#balance;
}
getBalance() {
return this.#balance;
}
}
// Trade-off: closures create a new function per instance (memory cost per method),
// while class methods on the prototype are shared - # fields give privacy
// WITHOUT this memory overhead, which is why they're preferred today
Object-Oriented JavaScript
Learn class structures, constructor methods, encapsulation, encapsulation fields, static methods, and prototype-based programming.
What are the four main principles of OOP and how does JavaScript support them?
PrincipleMeaningHow JavaScript supports itEncapsulationBundling data and methods, hiding internal detailsPriva...
How do private class fields (#field) work in JavaScript?
Private fields, prefixed with #, are truly inaccessible from outside the class - unlike the old convention of...
How do getters and setters work in JavaScript classes?
Getters and setters let you define methods that are accessed like properties, enabling computed properties and...
What is method overriding and how does super work with it?
Method overriding lets a subclass provide its own implementation of a method inherited from a parent class. Th...
What is composition and how does it compare to inheritance in JavaScript?
Composition builds objects by combining smaller, focused pieces of functionality rather than through a class h...
How does the instanceof operator work and what are its limitations?
instanceof checks whether a constructor's prototype exists anywhere in an object's prototype chain. It works w...
How do abstract-like classes and interfaces work in JavaScript, since it doesn't natively support them?
JavaScript has no built-in 'abstract class' or 'interface' keywords, but the same design intentions can be enf...
What is the difference between class fields and constructor-assigned properties?
Class fields (introduced in ES2022) let you declare instance properties directly in the class body, offering a...
How do you implement the Singleton pattern in JavaScript?
The Singleton pattern ensures a class has only one instance and provides a global point of access to it - usef...
How do you implement encapsulation in JavaScript before private fields existed (using closures)?
Before the # syntax was standardized, closures were the primary way to achieve true data privacy - variables i...