Moving beyond syntax: A design-first guide to the four pillars of OOP in TypeScript
Object-Oriented Programming (OOP) is more than a set of keywords; it is a strategy for managing software complexity. While most developers can define its pillars, a few apply them to build systems that survive change over time.
This guide explores the four pillars of OOP — and the essential rule of Composition — as architectural solutions to real-world problems.
We’ll use TypeScript programming language as code examples for clarity, but the ideas apply to any programming language or framework that you work with.
This is Part 1/2 of the series.
In Part 2, we will focus on applying these concepts in Angular applications.
So, let’s understand the essentials of OOP.
Why Do We Need Object-Oriented Programming?
The real-world problem in software engineering is complexity over time. Software fails when it becomes too tangled to change.
OOP addresses this by:
- Isolating changes: Fix a bug in one place without breaking the whole system.
- Reducing side effects: Preventing data corruption through controlled access.
- Modeling intent: Mapping code logic directly to business requirements.
OOP is not about writing classes.
It’s about designing code that can change safely.
The Four Pillars of OOP
Abstraction, Encapsulation, Inheritance, and Polymorphism.
1. Abstraction
The Concept: Abstraction is about focusing on what an object does instead of how it does it. It provides a simple “interface” to a complex internal process.
The Problem: You shouldn’t need to understand bank API protocols just to process a payment.
The Solution: Create a “contract” (Interface) that hides the internal details.
Abstraction exposes intent while hiding complexity.
Let’s see the code examples
Without Abstraction:
class Order {
processOrder(amount: number) {
console.log("Connecting to payment gateway...");
console.log(`Charging ${amount} via Razorpay`);
}
}
Problems with above code:
- Business logic depends on payment details
- Switching payment providers is hard
- Testing becomes difficult
With Abstraction:
// The Abstraction (The Contract)
interface PaymentGateway {
charge(amount: number): void;
}
// The Implementation (The Detail)
class RazorpayGateway implements PaymentGateway {
charge(amount: number): void {
console.log(`Charging ${amount} via Razorpay`);
}
}
class Order {
// We depend on the abstraction, not the specific tool.
constructor(private paymentGateway: PaymentGateway) {}
processOrder(amount: number) {
// business logic delegated respectively
this.paymentGateway.charge(amount);
}
}
Now:
- Order class depends on behavior , not implementation
- Payment logic can change independently
- Code becomes loosely coupled hence testable and flexible
2. Encapsulation
The Concept: Encapsulation keeps an object’s data (state) private and only allows access through strictly defined methods.
The Problem: If any piece of code can change a balance variable directly, you will eventually end up with negative balances or corrupted data.
The Solution: Protect the data so the object can enforce its own rules.
Let us see with an example.
Poor Encapsulation:
class BankAccount {
balance: number = 0;
}
const account = new BankAccount();
account.balance = -500; // mutable and incorrect
Nothing prevents incorrect usage / modification in the above code.
Proper Encapsulation:
class BankAccount {
private _balance: number = 0;
deposit(amount: number): void {
if (amount <= 0) throw new Error("Deposit must be positive");
this._balance += amount;
}
withdraw(amount: number): void {
if (amount > this._balance) throw new Error("Insufficient funds");
this._balance -= amount;
}
}
// usage
const account = new BankAccount();
account.balance = 500; // Not Allowed, throws error
Encapsulation is to protect the state of the Object.
3. Inheritance
The Concept: Inheritance allows a class to derive features from another class. It models an “Is-A” relationship.
The Problem: Writing the same login() or logout() logic for Admin, Customer, and Guest leads to code duplication.
The Solution: Move common logic to a base "Parent" class.
Code snippet:
class User {
constructor(public email: string) {}
logout() {
console.log(`${this.email} logged out safely.`);
}
}
class Admin extends User {
deleteUser(userId: string) {
console.log(`Admin deleted user ${userId}`);
}
}
Admin is a User— this relationship makes sense.
Inheritance Pitfalls:
- Deep inheritance trees
- Overriding base behavior unexpectedly
- Changes in parent classes breaking children
Inheritance increases coupling. Use it intentionally, not by default.
4. Polymorphism
The Concept: Polymorphism allows you to treat different objects as the same type. It allows a single function to work with different classes interchangeably.
The Problem: Massive if/else blocks that check types (e.g., if (type === 'sms') ... else if (type === 'email')).
The Solution: Use a common interface so the code doesn't care about the specific type.
Polymorphism replaces conditionals with interchangeable behavior.
Conditional Logic:
function sendNotification(type: string, message: string) {
if (type === "email") {
console.log("Sending Email:", message);
} else if (type === "sms") {
console.log("Sending SMS:", message);
}
}
Proper Polymorphism usage:
interface Notification {
send(message: string): void;
}
class EmailNotification implements Notification {
send(message: string) {
console.log("Sending Email:", message);
}
}
class SmsNotification implements Notification {
send(message: string) {
console.log("Sending SMS:", message);
}
}
// This function doesn't care WHAT service it is, as long as it can call .send()
function notifyAll(services: Notification[], message: string) {
services.forEach(s => s.send(message));
}
Now:
- No conditionals
- New behaviors don’t modify existing code
- The system stays extensible
Polymorphism removes conditional complexity.
Abstraction vs Polymorphism (A Commonly Confused Pair)
Abstraction and Polymorphism are often explained together — and that’s exactly why many of us may confuse the two.
They work together , but they solve different problems.
Abstraction defines what a system can do , its like a contract .
Polymorphism defines how that behavior can vary at _ **_runtime** .
Let’s clarify this using a real-world payment example.
Abstraction:
In a payment system, the business doesn’t care how a payment is processed.
It only cares that a payment can be processed or refunded.
Example:
export interface PaymentGateway {
processPayment(amount: number): boolean;
refundPayment(transactionId: string): boolean;
}
What this tells us:
- These operations exist
- Consumers can rely on them
- No implementation details are exposed
At this level:
- No Razorpay logic
- No Stripe logic
- Only business capability
This interface is pure abstraction.
Polymorphism:
Polymorphism allows different implementations of the same abstraction to be used interchangeably.
Each payment provider follows the same contract — but behaves differently.
Example:
class RazorPay implements PaymentGateway {
processPayment(amount: number): boolean {
// RazorPay-specific logic
return true;
}
refundPayment(transactionId: string): boolean {
// RazorPay-specific logic
return true;
}
}
class Stripe implements PaymentGateway {
processPayment(amount: number): boolean {
// Stripe-specific logic
return true;
}
refundPayment(transactionId: string): boolean {
// Stripe-specific logic
return true;
}
}
Here both classes:
- Implement the same abstraction
- Provide different internal behavior
- Can be swapped without changing usage code
This is polymorphism in action.
Where Abstraction and Polymorphism Meet
Now look at this line carefully:
const paymentGateway: PaymentGateway = new RazorPay();
So what is happening here?
- The variable type is PaymentGateway → Abstraction
- The actual object is RazorPay → Polymorphism
At runtime:
- The system calls the RazorPay implementation
Switching providers becomes trivial:
const paymentGateway: PaymentGateway = new Stripe();
The calling code doesn’t change — only the _behavior _ does.
Why This Design Matters in Real Systems?
This approach gives you:
- Loose coupling
- Easy extensibility
- Cleaner code
- Better testability
Adding a new provider will be easy in future:
class PayPal implements PaymentGateway {
processPayment(amount: number): boolean {
return true;
}
refundPayment(transactionId: string): boolean {
return true;
}
}
Composition Over Inheritance (A Modern OOP Rule)
Concept: Composition is the practice of combining simple objects to create complex ones.
Inheritance for Code Reuse:
class Logger {
log(message: string) {
console.log(message);
}
}
class Order extends Logger {
createOrder() {
this.log("Order created");
}
}
Here, Logger and Order classes are tightly coupled.
Let us see the code example how we can solve this issue.
Composition:
class Logger {
log(message: string) {
console.log(message);
}
}
class Order {
// inject dependency
constructor(private logger: Logger) {}
createOrder() {
this.logger.log("Order created");
}
}
Now this:
- Reduces coupling
- Improves testability
- Increases flexibility
Inheritance is a “Vertical” relationship (Is-A). Composition is a “Horizontal” relationship (Has-A). In modern engineering, we prefer Composition because it is easier to swap parts at runtime.
Summary
- Abstraction hides implementation details
- Encapsulation protects state and rules
- Inheritance models true hierarchies
- Polymorphism removes conditional logic
- Composition enables flexible design
OOP is about designing systems that can change without breaking.
What’s Next?
This article focused on learning OOP as a design mindset.
In Part 2 , we’ll apply these concepts directly to:
- Angular components
- Services and Dependency Injection
- Real Angular architecture patterns
Source Code & Resources
If you’d like to dive deeper or try the examples yourself, all source code including those discussed in this series is available on my GitHub repository: Oop-Typescript-Angular
Don’t forget to give this Repository a star ⭐ — not only does it help you stay updated when code changes, but it also shows appreciation for the content and motivates continued development.
Connect with Me!
Hi there! I’m Manish , a Senior Engineer, passionate about building robust web applications and exploring the ever-evolving world of tech. I believe in learning and growing together.
If this article sparked your interest in modern Angular, software architecture, or just a tech discussion, I’d love to connect with you!
🔗 Let’s connect on LinkedInfor more tech insights and discussions.
📧 Follow me on Mediumto catch Part 2 when it drops!
💻 *Explore my work on * GitHub

Top comments (0)