03 - OOP Pillars & Design Patterns ​
Why This Matters for Temple ​
Temple (ex-Zomato founding team) values strong fundamentals. OOP principles underpin every large-scale TypeScript/Node codebase. Expect questions that test whether you can reason about encapsulation boundaries, polymorphic APIs, and when to choose composition over inheritance.
Quick Reference (scan in 5 min) ​
| Pillar | Definition | TypeScript Feature | Key Example |
|---|---|---|---|
| Encapsulation | Bundle data + methods, hide internals | private, protected, readonly, #field | User class with private password, public validatePassword() |
| Abstraction | Hide complexity, expose simple interface | abstract classes, interface | Abstract PaymentProcessor with concrete Stripe/Razorpay |
| Inheritance | Create new class from existing class | extends, super(), method overriding | Base NotificationSender -> EmailSender, SMSSender |
| Polymorphism | Same interface, different behavior | Method overriding, function overloads | Shape.area() -> Circle, Rectangle, Triangle |
| Composition | Build behavior from parts, not parents | Object fields implementing interfaces | Vehicle with swappable Engine component |
1. Encapsulation ​
What it is: Bundling data and the methods that operate on that data into a single unit (class), while restricting direct access to the internals. Consumers interact through a controlled public API.
TypeScript access modifiers:
public-- default, accessible everywhereprivate-- accessible only within the class itselfprotected-- accessible within the class and its subclassesreadonly-- can only be assigned in the constructor#field-- ES2022 hard-private (truly private at runtime, not just a TS compile-time check)
Implementation ​
class User {
public readonly id: string;
public email: string;
#passwordHash: string; // hard-private: not accessible outside the class at runtime
constructor(id: string, email: string, password: string) {
this.id = id;
this.email = email;
this.#passwordHash = this.hash(password);
}
// Public API -- the only way to verify credentials
validatePassword(input: string): boolean {
return this.hash(input) === this.#passwordHash;
}
changePassword(currentPassword: string, newPassword: string): void {
if (!this.validatePassword(currentPassword)) {
throw new Error("Invalid current password");
}
this.#passwordHash = this.hash(newPassword);
}
// Private helper -- internals hidden from consumers
private hash(raw: string): string {
// simplified; use bcrypt in real code
let h = 0;
for (const ch of raw) {
h = (h * 31 + ch.charCodeAt(0)) | 0;
}
return h.toString(16);
}
}
const user = new User("1", "aayush@example.com", "s3cret");
console.log(user.validatePassword("s3cret")); // true
console.log(user.validatePassword("wrong")); // false
// user.#passwordHash; // SyntaxError at runtime -- truly privateGetters / Setters for Controlled Access ​
class BankAccount {
#balance: number;
constructor(initial: number) {
this.#balance = initial;
}
// Getter -- read-only access to the balance
get balance(): number {
return this.#balance;
}
// No direct setter -- deposits/withdrawals go through validated methods
deposit(amount: number): void {
if (amount <= 0) throw new Error("Amount must be positive");
this.#balance += amount;
}
withdraw(amount: number): void {
if (amount > this.#balance) throw new Error("Insufficient funds");
this.#balance -= amount;
}
}
const acct = new BankAccount(1000);
console.log(acct.balance); // 1000 (via getter)
acct.deposit(500);
acct.withdraw(200);
console.log(acct.balance); // 13002. Abstraction ​
What it is: Hiding complex implementation details and exposing only the essential interface. Consumers know what something does, not how it does it.
Abstract class vs Interface:
| Abstract Class | Interface | |
|---|---|---|
| Shared code | Can contain concrete methods and fields | Pure contract, no implementation |
| Instantiation | Cannot be instantiated directly | Cannot be instantiated |
| Single vs multiple | A class can extend only one | A class can implement many |
| When to use | Shared base behavior + enforced contract | Pure shape/contract, multiple conformance |
Implementation ​
// Abstract class: shared base behavior + enforced contract
abstract class PaymentProcessor {
protected transactionLog: string[] = [];
// Concrete method -- shared across all processors
getTransactionHistory(): string[] {
return [...this.transactionLog];
}
// Abstract method -- each processor MUST implement
abstract processPayment(amount: number, currency: string): Promise<boolean>;
abstract refund(transactionId: string): Promise<boolean>;
}
class StripeProcessor extends PaymentProcessor {
async processPayment(amount: number, currency: string): Promise<boolean> {
// Stripe-specific API call
console.log(`Stripe: charging ${amount} ${currency}`);
this.transactionLog.push(`stripe-${Date.now()}`);
return true;
}
async refund(transactionId: string): Promise<boolean> {
console.log(`Stripe: refunding ${transactionId}`);
return true;
}
}
class RazorpayProcessor extends PaymentProcessor {
async processPayment(amount: number, currency: string): Promise<boolean> {
console.log(`Razorpay: charging ${amount} ${currency}`);
this.transactionLog.push(`rzp-${Date.now()}`);
return true;
}
async refund(transactionId: string): Promise<boolean> {
console.log(`Razorpay: refunding ${transactionId}`);
return true;
}
}
// Consumer code works against the abstraction -- doesn't care which processor
async function checkout(processor: PaymentProcessor, amount: number) {
const success = await processor.processPayment(amount, "INR");
console.log(success ? "Payment successful" : "Payment failed");
}3. Inheritance ​
What it is: Creating a new class (child/subclass) from an existing class (parent/superclass). The child inherits all public and protected members and can override behavior.
Key mechanics:
extends-- establishes the parent-child relationshipsuper()-- calls the parent constructor (must be called beforethisin child constructor)- Method overriding -- child redefines a parent method
protected-- accessible in children but not outside the class hierarchy
Implementation ​
class NotificationSender {
protected recipientLog: string[] = [];
constructor(protected readonly appName: string) {}
// Base implementation -- children can override
formatMessage(message: string): string {
return `[${this.appName}] ${message}`;
}
send(to: string, message: string): void {
const formatted = this.formatMessage(message);
console.log(`Sending to ${to}: ${formatted}`);
this.recipientLog.push(to);
}
getRecipientLog(): string[] {
return [...this.recipientLog];
}
}
class EmailSender extends NotificationSender {
constructor(appName: string, private smtpHost: string) {
super(appName); // must call parent constructor first
}
// Override: emails get a subject-style format
formatMessage(message: string): string {
return `Subject: ${super.formatMessage(message)}`; // reuse parent logic
}
send(to: string, message: string): void {
console.log(`Connecting to ${this.smtpHost}...`);
super.send(to, message); // delegate to parent, which calls our overridden formatMessage
}
}
class SMSSender extends NotificationSender {
private readonly MAX_LENGTH = 160;
// Override: truncate for SMS character limit
formatMessage(message: string): string {
const base = super.formatMessage(message);
return base.length > this.MAX_LENGTH
? base.slice(0, this.MAX_LENGTH - 3) + "..."
: base;
}
}
const email = new EmailSender("Temple", "smtp.temple.io");
email.send("aayush@example.com", "Your order shipped!");
// "Connecting to smtp.temple.io..."
// "Sending to aayush@example.com: Subject: [Temple] Your order shipped!"
const sms = new SMSSender("Temple");
sms.send("+919876543210", "Your OTP is 482910");
// "Sending to +919876543210: [Temple] Your OTP is 482910"4. Polymorphism ​
What it is: The ability for objects of different classes to respond to the same method call in different ways. "Many forms" -- one interface, multiple behaviors.
Runtime Polymorphism (method overriding) ​
The parent reference can point to any child object. The correct method is resolved at runtime.
abstract class Shape {
constructor(public readonly name: string) {}
abstract area(): number;
describe(): string {
return `${this.name} with area ${this.area().toFixed(2)}`;
}
}
class Circle extends Shape {
constructor(private radius: number) {
super("Circle");
}
area(): number {
return Math.PI * this.radius ** 2;
}
}
class Rectangle extends Shape {
constructor(private width: number, private height: number) {
super("Rectangle");
}
area(): number {
return this.width * this.height;
}
}
class Triangle extends Shape {
constructor(private base: number, private height: number) {
super("Triangle");
}
area(): number {
return 0.5 * this.base * this.height;
}
}
// Polymorphic behavior: same function works with any Shape
function printTotalArea(shapes: Shape[]): void {
const total = shapes.reduce((sum, s) => sum + s.area(), 0);
shapes.forEach((s) => console.log(s.describe()));
console.log(`Total area: ${total.toFixed(2)}`);
}
printTotalArea([
new Circle(5), // Circle with area 78.54
new Rectangle(4, 6), // Rectangle with area 24.00
new Triangle(3, 8), // Triangle with area 12.00
]);
// Total area: 114.54Compile-time Polymorphism (function overloads) ​
TypeScript supports function overloading via overload signatures. The compiler picks the correct signature based on argument types.
function formatId(id: number): string;
function formatId(id: string): string;
function formatId(id: number | string): string {
if (typeof id === "number") {
return `ID-${id.toString().padStart(6, "0")}`;
}
return `ID-${id.toUpperCase()}`;
}
console.log(formatId(42)); // "ID-000042"
console.log(formatId("abc123")); // "ID-ABC123"5. Composition vs Inheritance ​
"Favor composition over inheritance" -- one of the most important OOP guidelines.
Why? ​
- Deep inheritance hierarchies become rigid and hard to change
- A change in a parent class ripples unpredictably through all descendants
- Multiple inheritance is not supported (TypeScript allows only single
extends) - Composition lets you mix and match behaviors at runtime
The Problem with Deep Inheritance ​
// Fragile hierarchy -- what if we need a HybridBoat? GasBoat?
class Vehicle {
move() { console.log("Moving..."); }
}
class ElectricVehicle extends Vehicle {
charge() { console.log("Charging battery..."); }
}
class ElectricCar extends ElectricVehicle {
openTrunk() { console.log("Opening trunk..."); }
}
// Now we need a GasCar... but it can't extend ElectricVehicle.
// We're stuck refactoring the entire tree.The Composition Approach ​
// Define capabilities as interfaces + standalone implementations
interface Engine {
start(): void;
refuel(): void;
}
interface Storage {
open(): void;
}
class ElectricEngine implements Engine {
start() { console.log("Electric motor humming..."); }
refuel() { console.log("Plugging in to charge..."); }
}
class GasEngine implements Engine {
start() { console.log("Gas engine roaring..."); }
refuel() { console.log("Filling gas tank..."); }
}
class HybridEngine implements Engine {
constructor(
private electric: ElectricEngine,
private gas: GasEngine,
) {}
start() { this.electric.start(); } // default to electric
refuel() {
this.electric.refuel();
this.gas.refuel();
}
}
class Trunk implements Storage {
open() { console.log("Opening trunk..."); }
}
// Compose any combination -- no rigid hierarchy
class Vehicle {
constructor(
private engine: Engine,
private storage?: Storage,
) {}
start() { this.engine.start(); }
refuel() { this.engine.refuel(); }
openStorage() { this.storage?.open(); }
}
// Easy to build any variant:
const electricCar = new Vehicle(new ElectricEngine(), new Trunk());
const gasCar = new Vehicle(new GasEngine(), new Trunk());
const hybridCar = new Vehicle(
new HybridEngine(new ElectricEngine(), new GasEngine()),
new Trunk(),
);
electricCar.start(); // "Electric motor humming..."
gasCar.start(); // "Gas engine roaring..."
hybridCar.start(); // "Electric motor humming..."When Inheritance IS Appropriate ​
Use inheritance when:
- There is a genuine is-a relationship that is unlikely to change
- The parent class provides substantial shared behavior (not just a type contract)
- The hierarchy is shallow (1-2 levels deep)
Example: Error -> HttpError -> NotFoundError is a natural, stable hierarchy.
6. Design Patterns Cross-Reference ​
Detailed implementations of the following design patterns are covered in 02 - Design Patterns (TypeScript).
| Pattern | One-Line Summary |
|---|---|
| Singleton | Private constructor + getInstance() ensures exactly one instance |
| Observer | Subject maintains a list of observers and notifies them on state changes |
| Strategy | Define a family of algorithms behind an interface, swap them at runtime |
| Factory | A method that returns the correct subclass/implementation based on input |
| Builder | Construct complex objects step-by-step via method chaining + .build() |
| Chain of Responsibility | Pass a request through a chain of handlers; each decides to process or forward |