Skip to main content

OOP Concepts & Design Patterns

The Four Pillars

Encapsulation

Hide state, expose behavior. Make fields private, provide methods for access.

Keep internal state private. Only expose what callers need — not how it's stored.

// Bad: exposing raw state
class BankAccount {
balance: number = 0;
}
account.balance -= 500; // external code mutates state directly

// Good: encapsulated
class BankAccount {
private balance: number = 0;

deposit(amount: number): void {
if (amount <= 0) throw new Error('Invalid amount');
this.balance += amount;
}

withdraw(amount: number): void {
if (amount > this.balance) throw new Error('Insufficient funds');
this.balance -= amount;
}

getBalance(): number {
return this.balance;
}
}

Why it matters: Validation lives in one place. Internal representation can change (e.g. switch to BigInt) without breaking callers.


Abstraction

Define interfaces for variations. Multiple payment methods? Different vehicle types? Create an interface.

Model the what, not the how. Callers depend on the interface, not the concrete class.

// Interface defines the contract
interface PaymentMethod {
charge(amount: number): Promise<void>;
refund(transactionId: string): Promise<void>;
}

// Concrete implementations — each handles its own details
class CreditCard implements PaymentMethod {
async charge(amount: number) {
/* Stripe API call */
}
async refund(transactionId: string) {
/* Stripe refund */
}
}

class PayPal implements PaymentMethod {
async charge(amount: number) {
/* PayPal SDK */
}
async refund(transactionId: string) {
/* PayPal refund */
}
}

// Caller is insulated from implementation
class Checkout {
constructor(private payment: PaymentMethod) {}

async complete(total: number) {
await this.payment.charge(total);
}
}

Why it matters: Adding a new payment method (e.g. CryptoWallet) requires zero changes to Checkout.


Polymorphism

Let objects handle themselves. No type checking, no switch statements on types.

Push behavior into objects; let the runtime dispatch the right method.

// Bad: type-switching anti-pattern
function makeSound(animal: Animal) {
if (animal.type === 'dog') console.log('Woof');
else if (animal.type === 'cat') console.log('Meow');
// Adding a new animal requires editing this function
}

// Good: polymorphic dispatch
interface Animal {
speak(): void;
}

class Dog implements Animal {
speak() {
console.log('Woof');
}
}

class Cat implements Animal {
speak() {
console.log('Meow');
}
}

class Duck implements Animal {
speak() {
console.log('Quack');
}
}

function makeSound(animal: Animal) {
animal.speak(); // no conditionals — open/closed principle
}

Why it matters: Adding Duck requires no changes to makeSound. Scales to N types without touching existing code.


Inheritance

Compose behavior, don't inherit it. Reach for interfaces first; use inheritance only when sharing stable implementation.

Inheritance creates tight coupling. Prefer composition: inject collaborators, delegate to them.

// Fragile inheritance — base class changes break subclasses
class Logger {
log(msg: string) {
console.log(msg);
}
}

class TimestampedLogger extends Logger {
log(msg: string) {
super.log(`[${new Date().toISOString()}] ${msg}`);
}
}

// Prefer composition — decouple via interface
interface Logger {
log(msg: string): void;
}

class ConsoleLogger implements Logger {
log(msg: string) {
console.log(msg);
}
}

class TimestampedLogger implements Logger {
constructor(private inner: Logger) {}

log(msg: string) {
this.inner.log(`[${new Date().toISOString()}] ${msg}`);
}
}

// Can wrap any Logger — FileLogger, SilentLogger, etc.
const logger = new TimestampedLogger(new ConsoleLogger());

When inheritance IS appropriate:

  • Sharing stable, well-tested implementation (e.g. AbstractList)
  • Framework extension points designed for inheritance
  • Clear "is-a" relationship that won't change

Favor composition when:

  • You want to mix in behaviors from multiple sources
  • The base class might evolve independently
  • You need runtime flexibility (swap implementations)

Quick Reference

PillarOne-linerSignal to apply
EncapsulationHide state, expose behaviorPublic fields being mutated from outside
AbstractionInterface over implementationMultiple variants of the same concept
PolymorphismLet objects decideif type === X / switch(type) in calling code
InheritanceCompose first, inherit lastShared implementation — only after interfaces fail