What is Object-Oriented Programming?
Object-Oriented Programming (OOP) is a programming paradigm that organises code around objects — bundles of data (fields/attributes) and behaviour (methods) — rather than around functions and procedures. The goal is to model real-world entities and their interactions in a way that is modular, reusable, and maintainable.
OOP is built on four core principles, commonly called the four pillars. Every interview that asks about OOP is really asking about these four concepts.
1. Encapsulation
Encapsulation means bundling data and the methods that operate on it together, and controlling access to the internal state. Instead of allowing any code to directly modify an object's fields, you expose a controlled public interface (getters/setters, methods) and hide implementation details as private.
The benefit: you can change the internal implementation without breaking the code that uses the class, as long as the public interface stays the same.
class BankAccount {
#balance = 0; // private field (ES2022 # syntax)
constructor(initialBalance) {
if (initialBalance < 0) throw new Error('Balance cannot be negative');
this.#balance = initialBalance;
}
// Public interface — controlled access
deposit(amount) {
if (amount <= 0) throw new Error('Amount must be positive');
this.#balance += amount;
}
withdraw(amount) {
if (amount > this.#balance) throw new Error('Insufficient funds');
this.#balance -= amount;
}
get balance() { return this.#balance; } // read-only getter
}
const acc = new BankAccount(100);
acc.deposit(50);
console.log(acc.balance); // 150
// acc.#balance = 999; // SyntaxError — private field not accessible
2. Inheritance
Inheritance lets a class (child / subclass) acquire the properties and methods of another class (parent / superclass). This models an IS-A relationship: a Dog IS-A Animal. The child can reuse the parent's code and also extend or override it.
class Animal {
constructor(name) { this.name = name; }
speak() { return `${this.name} makes a sound.`; }
toString() { return `Animal(${this.name})`; }
}
class Dog extends Animal {
constructor(name, breed) {
super(name); // must call parent constructor first
this.breed = breed;
}
// Override parent method
speak() { return `${this.name} barks.`; }
fetch() { return `${this.name} fetches the ball!`; }
}
class Cat extends Animal {
speak() { return `${this.name} meows.`; }
}
const dog = new Dog('Rex', 'Labrador');
console.log(dog.speak()); // "Rex barks."
console.log(dog.fetch()); // "Rex fetches the ball!"
console.log(dog instanceof Animal); // true — Dog IS-A Animal
Prefer composition over inheritance
Deep inheritance chains (A → B → C → D) become fragile and hard to change. A common rule: if the relationship is HAS-A (a car HAS-A engine), use composition. If it's truly IS-A (a dog IS-A animal), use inheritance. Many engineers recommend keeping inheritance to 1–2 levels.
3. Polymorphism
Polymorphism means one interface, many implementations. The same method name behaves differently depending on the object it's called on. This lets you write generic code that works across different types.
Runtime polymorphism (method overriding): the correct method is determined at runtime based on the actual object type. Compile-time polymorphism (method overloading): same method name with different parameter signatures — JavaScript doesn't have this natively, but TypeScript simulates it.
// Runtime polymorphism — same method name, different behaviour
const animals = [new Dog('Rex', 'Lab'), new Cat('Whiskers'), new Animal('Generic')];
// Polymorphic call — no need to know the exact type
animals.forEach(a => console.log(a.speak()));
// "Rex barks."
// "Whiskers meows."
// "Generic makes a sound."
// Strategy pattern — polymorphism through an interface
class Renderer {
render(data) { throw new Error('render() must be implemented'); }
}
class JsonRenderer extends Renderer { render(d) { return JSON.stringify(d); } }
class CsvRenderer extends Renderer { render(d) { return Object.values(d).join(','); } }
function export(data, renderer) { return renderer.render(data); }
// Works with any renderer — new formats don't require changing export()
4. Abstraction
Abstraction means exposing only the essential details and hiding the complex implementation. A user of a class should be able to use it without knowing how it works internally — just what it does.
In JavaScript, abstraction is achieved through private fields, classes, and design conventions. TypeScript adds abstract classes and interface keyword.
// TypeScript abstract class — defines a contract; can't be instantiated directly
abstract class Shape {
abstract area(): number; // subclasses MUST implement this
abstract perimeter(): number;
describe() { // shared concrete method
return `Area: ${this.area().toFixed(2)}, Perimeter: ${this.perimeter().toFixed(2)}`;
}
}
class Circle extends Shape {
constructor(private r: number) { super(); }
area() { return Math.PI * this.r ** 2; }
perimeter() { return 2 * Math.PI * this.r; }
}
class Rectangle extends Shape {
constructor(private w: number, private h: number) { super(); }
area() { return this.w * this.h; }
perimeter() { return 2 * (this.w + this.h); }
}
// You work with Shape — you don't care about Circle or Rectangle internals
function printShapes(shapes: Shape[]) {
shapes.forEach(s => console.log(s.describe()));
}
SOLID Principles
SOLID is a set of five design principles for writing maintainable object-oriented code. They were popularised by Robert C. Martin (Uncle Bob) and are the most common OOP topic in senior engineering interviews.
S — Single Responsibility Principle
A class should have one, and only one, reason to change. If a class handles both business logic and database persistence, changes to either force re-testing and risk breaking the other.
// Violation — UserService does too much
class UserService {
saveUser(user) { /* DB logic */ }
sendWelcomeEmail(user) { /* email logic */ } // ← separate responsibility
generateReport() { /* report logic */ } // ← separate responsibility
}
// Better — separate classes, each with one job
class UserRepository { save(user) { /* DB */ } }
class EmailService { sendWelcome(user) { /* email */ } }
class UserReportService{ generate() { /* report */ } }
O — Open/Closed Principle
Software entities should be open for extension, closed for modification. Add new behaviour by adding new code, not by changing existing tested code.
// Violation — every new payment type requires modifying processPayment()
function processPayment(type, amount) {
if (type === 'credit') { /* ... */ }
else if (type === 'paypal') { /* ... */ } // adding this broke existing code
}
// Better — extend by adding new payment classes
class PaymentProcessor { process(amount) { throw new Error('implement me'); } }
class CreditPayment extends PaymentProcessor { process(a) { /* ... */ } }
class PaypalPayment extends PaymentProcessor { process(a) { /* ... */ } }
// Adding CryptoPayment needs zero changes to existing classes
L — Liskov Substitution Principle
Objects of a subclass should be substitutable for objects of their superclass without breaking the program. If you have to check the type of an object before calling a method, LSP is probably violated.
I — Interface Segregation Principle
Clients should not be forced to depend on methods they don't use. Split fat interfaces into smaller, more specific ones.
D — Dependency Inversion Principle
High-level modules should not depend on low-level modules. Both should depend on abstractions. Depend on interfaces, not concrete implementations. This is the foundation of dependency injection.
Common Design Patterns
Design patterns are reusable solutions to common software design problems. They're not code you copy — they're templates you adapt.
Singleton — one instance
class Database {
static #instance = null;
#conn;
constructor() { this.#conn = createConnection(); } // expensive operation
static getInstance() {
if (!Database.#instance) Database.#instance = new Database();
return Database.#instance;
}
}
// Use: Database.getInstance() always returns the same object
Observer — event pub/sub
class EventEmitter {
#listeners = new Map();
on(event, fn) {
if (!this.#listeners.has(event)) this.#listeners.set(event, []);
this.#listeners.get(event).push(fn);
}
emit(event, data) {
(this.#listeners.get(event) || []).forEach(fn => fn(data));
}
}
const emitter = new EventEmitter();
emitter.on('login', user => console.log(`${user.name} logged in`));
emitter.emit('login', { name: 'Alice' }); // triggers all 'login' listeners
Factory — delegate object creation
class NotificationFactory {
static create(type, message) {
const types = { email: EmailNotif, sms: SmsNotif, push: PushNotif };
const NotifClass = types[type];
if (!NotifClass) throw new Error(`Unknown notification type: ${type}`);
return new NotifClass(message);
}
}
// Caller doesn't know which class it's getting — just uses .send()
const notif = NotificationFactory.create('email', 'Hello!');
notif.send();
Key Takeaways
- Encapsulation: hide state, expose behaviour. Private fields + public methods.
- Inheritance: IS-A relationships. Prefer composition (HAS-A) when uncertain.
- Polymorphism: one interface, many implementations. Write code to interfaces.
- Abstraction: expose what you do, hide how you do it. Abstract classes / interfaces.
- SOLID: keep classes small and focused (SRP), extend without modifying (OCP), depend on abstractions (DIP).
- Patterns: Singleton (one instance), Observer (events), Factory (creation), Strategy (swappable algorithms).