Skip to content

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) ​

PillarDefinitionTypeScript FeatureKey Example
EncapsulationBundle data + methods, hide internalsprivate, protected, readonly, #fieldUser class with private password, public validatePassword()
AbstractionHide complexity, expose simple interfaceabstract classes, interfaceAbstract PaymentProcessor with concrete Stripe/Razorpay
InheritanceCreate new class from existing classextends, super(), method overridingBase NotificationSender -> EmailSender, SMSSender
PolymorphismSame interface, different behaviorMethod overriding, function overloadsShape.area() -> Circle, Rectangle, Triangle
CompositionBuild behavior from parts, not parentsObject fields implementing interfacesVehicle 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 everywhere
  • private -- accessible only within the class itself
  • protected -- accessible within the class and its subclasses
  • readonly -- can only be assigned in the constructor
  • #field -- ES2022 hard-private (truly private at runtime, not just a TS compile-time check)

Implementation ​

ts
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 private

Getters / Setters for Controlled Access ​

ts
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);  // 1300

2. 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 ClassInterface
Shared codeCan contain concrete methods and fieldsPure contract, no implementation
InstantiationCannot be instantiated directlyCannot be instantiated
Single vs multipleA class can extend only oneA class can implement many
When to useShared base behavior + enforced contractPure shape/contract, multiple conformance

Implementation ​

ts
// 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 relationship
  • super() -- calls the parent constructor (must be called before this in child constructor)
  • Method overriding -- child redefines a parent method
  • protected -- accessible in children but not outside the class hierarchy

Implementation ​

ts
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.

ts
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.54

Compile-time Polymorphism (function overloads) ​

TypeScript supports function overloading via overload signatures. The compiler picks the correct signature based on argument types.

ts
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 ​

ts
// 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 ​

ts
// 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).

PatternOne-Line Summary
SingletonPrivate constructor + getInstance() ensures exactly one instance
ObserverSubject maintains a list of observers and notifies them on state changes
StrategyDefine a family of algorithms behind an interface, swap them at runtime
FactoryA method that returns the correct subclass/implementation based on input
BuilderConstruct complex objects step-by-step via method chaining + .build()
Chain of ResponsibilityPass a request through a chain of handlers; each decides to process or forward

Frontend interview preparation reference.