Prerequisites
- Basic understanding of JavaScript ๐
- TypeScript installation โก
- VS Code or preferred IDE ๐ป
What you'll learn
- Understand the concept fundamentals ๐ฏ
- Apply the concept in real projects ๐๏ธ
- Debug common issues ๐
- Write type-safe code โจ
๐ฏ Introduction
Welcome to this exciting tutorial on Liskov Substitution Principle (LSP)! ๐ In this guide, weโll explore one of the most important principles in object-oriented design that helps you write rock-solid TypeScript code.
Youโll discover how LSP can transform your TypeScript development by ensuring your inheritance hierarchies behave predictably. Whether youโre building web applications ๐, server-side code ๐ฅ๏ธ, or libraries ๐, understanding LSP is essential for creating maintainable and bug-free code.
By the end of this tutorial, youโll feel confident applying LSP in your own projects! Letโs dive in! ๐โโ๏ธ
๐ Understanding Liskov Substitution Principle
๐ค What is LSP?
The Liskov Substitution Principle is like a promise between parent and child classes ๐ค. Think of it as a contract that says: โIf you can use a parent class somewhere, you should be able to swap in any of its child classes without breaking anything!โ
In TypeScript terms, LSP ensures that derived classes can be used interchangeably with their base classes without altering the correctness of the program. This means you can:
- โจ Replace parent instances with child instances seamlessly
- ๐ Build flexible and extensible code architectures
- ๐ก๏ธ Prevent unexpected runtime errors
๐ก Why Use LSP?
Hereโs why developers love LSP:
- Type Safety ๐: Catch inheritance issues at compile-time
- Better Architecture ๐ป: Create more predictable class hierarchies
- Code Reusability ๐: Build truly interchangeable components
- Refactoring Confidence ๐ง: Change implementations without fear
Real-world example: Imagine building a payment system ๐ณ. With LSP, you can swap between CreditCard, DebitCard, and PayPal processors without changing your checkout code!
๐ง Basic Syntax and Usage
๐ Simple Example
Letโs start with a friendly example:
// ๐ Hello, LSP!
abstract class Bird {
name: string;
constructor(name: string) {
this.name = name;
}
// ๐ต All birds make sounds
abstract makeSound(): string;
// ๐ Movement method
abstract move(): string;
}
// โ
Good implementation - respects LSP
class Sparrow extends Bird {
makeSound(): string {
return `${this.name} chirps! ๐ถ`;
}
move(): string {
return `${this.name} flies through the air! ๐ฆ
`;
}
}
// โ
Also good - different movement, still valid
class Penguin extends Bird {
makeSound(): string {
return `${this.name} squawks! ๐ง`;
}
move(): string {
return `${this.name} waddles and swims! ๐`;
}
}
๐ก Explanation: Notice how both birds implement move()
differently but still fulfill the contract. This is LSP in action!
๐ฏ Common Patterns
Here are patterns youโll use daily:
// ๐๏ธ Pattern 1: Proper inheritance
abstract class Shape {
abstract area(): number;
abstract perimeter(): number;
}
class Rectangle extends Shape {
constructor(
protected width: number,
protected height: number
) {
super();
}
area(): number {
return this.width * this.height;
}
perimeter(): number {
return 2 * (this.width + this.height);
}
}
// โ
Square is a special rectangle
class Square extends Rectangle {
constructor(side: number) {
super(side, side); // ๐ฏ Both width and height are the same
}
}
// ๐ Pattern 2: Interface substitution
interface PaymentProcessor {
processPayment(amount: number): Promise<boolean>;
validateCard?(cardNumber: string): boolean;
}
class CreditCardProcessor implements PaymentProcessor {
async processPayment(amount: number): Promise<boolean> {
console.log(`๐ณ Processing $${amount} via credit card`);
return true;
}
validateCard(cardNumber: string): boolean {
return cardNumber.length === 16;
}
}
๐ก Practical Examples
๐ Example 1: E-Commerce Order System
Letโs build something real:
// ๐๏ธ Base order class
abstract class Order {
protected items: Array<{name: string, price: number}> = [];
addItem(name: string, price: number): void {
this.items.push({name, price});
console.log(`โ
Added ${name} to order!`);
}
// ๐ฐ Calculate total - must be consistent across all orders
abstract calculateTotal(): number;
// ๐ฆ Process order - behavior can vary
abstract processOrder(): Promise<string>;
}
// ๐ช Regular customer order
class RegularOrder extends Order {
calculateTotal(): number {
return this.items.reduce((sum, item) => sum + item.price, 0);
}
async processOrder(): Promise<string> {
const total = this.calculateTotal();
console.log(`๐ฆ Processing regular order: $${total}`);
return `Order processed! Total: $${total}`;
}
}
// ๐ Premium customer order with discount
class PremiumOrder extends Order {
private discount = 0.1; // 10% discount
calculateTotal(): number {
const subtotal = this.items.reduce((sum, item) => sum + item.price, 0);
return subtotal * (1 - this.discount);
}
async processOrder(): Promise<string> {
const total = this.calculateTotal();
console.log(`๐ Processing premium order with 10% discount: $${total}`);
return `Premium order processed! Total: $${total} (10% off!)`;
}
}
// ๐ Express order with shipping
class ExpressOrder extends Order {
private shippingFee = 15;
calculateTotal(): number {
const subtotal = this.items.reduce((sum, item) => sum + item.price, 0);
return subtotal + this.shippingFee;
}
async processOrder(): Promise<string> {
const total = this.calculateTotal();
console.log(`๐ Processing express order with shipping: $${total}`);
return `Express order processed! Total: $${total} (includes $15 shipping)`;
}
}
// ๐ฎ Using our orders interchangeably!
async function processAnyOrder(order: Order): Promise<void> {
order.addItem("TypeScript Book ๐", 29.99);
order.addItem("Coffee โ", 4.99);
const result = await order.processOrder();
console.log(result);
}
// All orders work with the same function! ๐
const regular = new RegularOrder();
const premium = new PremiumOrder();
const express = new ExpressOrder();
processAnyOrder(regular); // Works! โ
processAnyOrder(premium); // Works! โ
processAnyOrder(express); // Works! โ
๐ฏ Try it yourself: Add a BulkOrder
class that requires minimum 10 items!
๐ฎ Example 2: Game Character System
Letโs make it fun:
// ๐ Base character class
abstract class GameCharacter {
constructor(
public name: string,
protected health: number,
protected power: number
) {}
// ๐ก๏ธ Attack must work consistently
abstract attack(): number;
// ๐ก๏ธ Defend must reduce damage
abstract defend(damage: number): void;
// ๐ซ Special ability
abstract useSpecialAbility(): string;
// โค๏ธ Check if alive
isAlive(): boolean {
return this.health > 0;
}
}
// ๐ก๏ธ Warrior class
class Warrior extends GameCharacter {
constructor(name: string) {
super(name, 100, 15);
}
attack(): number {
console.log(`โ๏ธ ${this.name} swings sword!`);
return this.power;
}
defend(damage: number): void {
const reducedDamage = damage * 0.7; // Warriors have armor
this.health -= reducedDamage;
console.log(`๐ก๏ธ ${this.name} blocks! Takes ${reducedDamage} damage`);
}
useSpecialAbility(): string {
this.power *= 2;
return `๐ช ${this.name} enters RAGE mode! Power doubled!`;
}
}
// ๐งโโ๏ธ Mage class
class Mage extends GameCharacter {
private mana: number = 50;
constructor(name: string) {
super(name, 70, 20);
}
attack(): number {
if (this.mana >= 5) {
this.mana -= 5;
console.log(`โจ ${this.name} casts fireball!`);
return this.power;
}
console.log(`๐ฐ ${this.name} is out of mana!`);
return 5; // Weak staff attack
}
defend(damage: number): void {
if (this.mana >= 10) {
this.mana -= 10;
const reducedDamage = damage * 0.5;
this.health -= reducedDamage;
console.log(`๐ ${this.name} casts shield! Takes ${reducedDamage} damage`);
} else {
this.health -= damage;
console.log(`๐ฑ ${this.name} takes full ${damage} damage!`);
}
}
useSpecialAbility(): string {
this.mana = 100;
return `๐ซ ${this.name} restores mana to full!`;
}
}
// ๐น Archer class
class Archer extends GameCharacter {
private arrows: number = 30;
constructor(name: string) {
super(name, 80, 18);
}
attack(): number {
if (this.arrows > 0) {
this.arrows--;
console.log(`๐น ${this.name} shoots arrow! (${this.arrows} left)`);
return this.power;
}
console.log(`๐ ${this.name} is out of arrows!`);
return 3; // Dagger attack
}
defend(damage: number): void {
const dodgeChance = Math.random();
if (dodgeChance > 0.5) {
console.log(`๐ช๏ธ ${this.name} dodges the attack!`);
} else {
this.health -= damage;
console.log(`๐ฅ ${this.name} takes ${damage} damage!`);
}
}
useSpecialAbility(): string {
this.arrows += 15;
return `๐ฏ ${this.name} crafts 15 new arrows!`;
}
}
// ๐ฎ Battle system works with any character!
function battle(char1: GameCharacter, char2: GameCharacter): void {
console.log(`โ๏ธ ${char1.name} VS ${char2.name}! FIGHT!`);
while (char1.isAlive() && char2.isAlive()) {
// Character 1 attacks
const damage1 = char1.attack();
char2.defend(damage1);
if (!char2.isAlive()) break;
// Character 2 attacks
const damage2 = char2.attack();
char1.defend(damage2);
}
const winner = char1.isAlive() ? char1.name : char2.name;
console.log(`๐ ${winner} wins!`);
}
๐ Advanced Concepts
๐งโโ๏ธ Advanced Topic 1: Covariance and Contravariance
When youโre ready to level up, understand variance in LSP:
// ๐ฏ Advanced return type covariance
abstract class Animal {
abstract makeSound(): string;
abstract getOffspring(): Animal;
}
class Dog extends Animal {
makeSound(): string {
return "Woof! ๐";
}
// โ
Covariant return - more specific type is OK
getOffspring(): Dog {
return new Dog();
}
}
// ๐ช Advanced parameter contravariance
interface EventHandler<T> {
handle(event: T): void;
}
class MouseEvent {
x: number = 0;
y: number = 0;
}
class ClickEvent extends MouseEvent {
button: number = 0;
}
// โ
Handler for general events can handle specific events
class GeneralHandler implements EventHandler<MouseEvent> {
handle(event: MouseEvent): void {
console.log(`๐ฑ๏ธ Mouse at (${event.x}, ${event.y})`);
}
}
// Can use GeneralHandler where ClickHandler is expected!
const handler: EventHandler<ClickEvent> = new GeneralHandler();
๐๏ธ Advanced Topic 2: Method Preconditions and Postconditions
For the brave developers:
// ๐ Ensuring LSP with proper contracts
abstract class BankAccount {
protected balance: number = 0;
// ๐ Contract: amount must be positive
deposit(amount: number): void {
if (amount <= 0) {
throw new Error("Amount must be positive! ๐ธ");
}
this.balance += amount;
}
// ๐ Contract: can't withdraw more than balance
abstract withdraw(amount: number): void;
getBalance(): number {
return this.balance;
}
}
// โ
Good - respects parent's contract
class CheckingAccount extends BankAccount {
withdraw(amount: number): void {
if (amount <= 0) {
throw new Error("Amount must be positive! ๐ธ");
}
if (amount > this.balance) {
throw new Error("Insufficient funds! ๐ข");
}
this.balance -= amount;
console.log(`โ
Withdrew $${amount}`);
}
}
// โ
Good - adds feature without breaking contract
class PremiumAccount extends BankAccount {
private overdraftLimit = 100;
withdraw(amount: number): void {
if (amount <= 0) {
throw new Error("Amount must be positive! ๐ธ");
}
if (amount > this.balance + this.overdraftLimit) {
throw new Error("Exceeds overdraft limit! ๐ซ");
}
this.balance -= amount;
console.log(`โ
Withdrew $${amount} (overdraft available)`);
}
}
โ ๏ธ Common Pitfalls and Solutions
๐ฑ Pitfall 1: Breaking Method Contracts
// โ Wrong way - changes behavior unexpectedly!
class BrokenSquare extends Rectangle {
setWidth(width: number): void {
this.width = width;
this.height = width; // ๐ฅ Unexpected side effect!
}
setHeight(height: number): void {
this.width = height; // ๐ฅ Violates rectangle contract!
this.height = height;
}
}
// โ
Correct way - respect the contract!
class ProperSquare {
constructor(private side: number) {}
setSide(side: number): void {
this.side = side;
}
area(): number {
return this.side * this.side;
}
}
๐คฏ Pitfall 2: Strengthening Preconditions
// โ Dangerous - adds stricter requirements!
class RestrictedAccount extends BankAccount {
withdraw(amount: number): void {
// ๐ฅ Parent allows any positive amount!
if (amount < 10) {
throw new Error("Minimum withdrawal is $10!");
}
// ... rest of implementation
}
}
// โ
Safe - maintain or weaken preconditions!
class FlexibleAccount extends BankAccount {
withdraw(amount: number): void {
if (amount <= 0) {
console.log("โ ๏ธ Invalid amount, no action taken");
return; // Graceful handling instead of throwing
}
if (amount > this.balance) {
console.log("โ ๏ธ Insufficient funds!");
return;
}
this.balance -= amount;
}
}
๐ ๏ธ Best Practices
- ๐ฏ Design by Contract: Define clear contracts for base classes
- ๐ Document Expectations: Make method contracts explicit
- ๐ก๏ธ Preserve Behavior: Derived classes should extend, not replace
- ๐จ Favor Composition: Sometimes composition is better than inheritance
- โจ Test Substitutability: Ensure derived classes pass base class tests
๐งช Hands-On Exercise
๐ฏ Challenge: Build a Notification System
Create a type-safe notification system following LSP:
๐ Requirements:
- โ
Base
Notification
class with send method - ๐ท๏ธ Different types: Email, SMS, Push notifications
- ๐ค Each type has specific properties but same interface
- ๐ Support scheduling and immediate sending
- ๐จ Each notification type needs its own emoji!
๐ Bonus Points:
- Add retry logic for failed sends
- Implement notification templates
- Create a notification queue system
๐ก Solution
๐ Click to see solution
// ๐ฏ Our LSP-compliant notification system!
abstract class Notification {
constructor(
protected recipient: string,
protected message: string,
protected emoji: string
) {}
// ๐ค Core contract - all notifications must implement
abstract send(): Promise<boolean>;
// ๐
Schedule for later (optional override)
async scheduleSend(delayMs: number): Promise<boolean> {
console.log(`โฐ Scheduled ${this.emoji} notification in ${delayMs}ms`);
await new Promise(resolve => setTimeout(resolve, delayMs));
return this.send();
}
// ๐ Retry logic (shared behavior)
async sendWithRetry(maxRetries: number = 3): Promise<boolean> {
for (let i = 0; i < maxRetries; i++) {
try {
const success = await this.send();
if (success) return true;
} catch (error) {
console.log(`โ ๏ธ Attempt ${i + 1} failed, retrying...`);
}
}
return false;
}
}
// ๐ง Email notification
class EmailNotification extends Notification {
private subject: string;
constructor(recipient: string, subject: string, message: string) {
super(recipient, message, "๐ง");
this.subject = subject;
}
async send(): Promise<boolean> {
console.log(`${this.emoji} Sending email to ${this.recipient}`);
console.log(` Subject: ${this.subject}`);
console.log(` Message: ${this.message}`);
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 100));
return true;
}
}
// ๐ฑ SMS notification
class SMSNotification extends Notification {
constructor(phoneNumber: string, message: string) {
super(phoneNumber, message.substring(0, 160), "๐ฑ"); // SMS limit
}
async send(): Promise<boolean> {
console.log(`${this.emoji} Sending SMS to ${this.recipient}`);
console.log(` Message: ${this.message}`);
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 50));
return true;
}
}
// ๐ Push notification
class PushNotification extends Notification {
private title: string;
private icon?: string;
constructor(
deviceId: string,
title: string,
message: string,
icon?: string
) {
super(deviceId, message, "๐");
this.title = title;
this.icon = icon;
}
async send(): Promise<boolean> {
console.log(`${this.emoji} Sending push to device ${this.recipient}`);
console.log(` Title: ${this.title}`);
console.log(` Message: ${this.message}`);
if (this.icon) {
console.log(` Icon: ${this.icon}`);
}
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 75));
return true;
}
}
// ๐ Notification manager works with any notification type!
class NotificationManager {
private queue: Notification[] = [];
// โ Add to queue
addNotification(notification: Notification): void {
this.queue.push(notification);
console.log(`โ
Added ${notification.constructor.name} to queue`);
}
// ๐ Process all notifications
async processQueue(): Promise<void> {
console.log(`๐ฌ Processing ${this.queue.length} notifications...`);
for (const notification of this.queue) {
// All notifications work the same way! LSP in action! ๐
const success = await notification.sendWithRetry(2);
if (success) {
console.log(`โ
Notification sent successfully!`);
} else {
console.log(`โ Failed to send notification`);
}
}
this.queue = [];
console.log(`๐ Queue processed!`);
}
}
// ๐ฎ Test it out!
const manager = new NotificationManager();
// All different types work seamlessly!
manager.addNotification(
new EmailNotification(
"[email protected]",
"Welcome!",
"Thanks for joining! ๐"
)
);
manager.addNotification(
new SMSNotification(
"+1234567890",
"Your code is 123456"
)
);
manager.addNotification(
new PushNotification(
"device-123",
"New Message",
"You have a new message! ๐ฌ",
"message-icon.png"
)
);
// Process them all with one method! ๐
manager.processQueue();
๐ Key Takeaways
Youโve learned so much! Hereโs what you can now do:
- โ Create LSP-compliant hierarchies with confidence ๐ช
- โ Avoid common inheritance mistakes that trip up beginners ๐ก๏ธ
- โ Apply behavioral subtyping in real projects ๐ฏ
- โ Debug inheritance issues like a pro ๐
- โ Build flexible architectures with TypeScript! ๐
Remember: LSP is about keeping promises. When a derived class promises to be a valid substitute, it must deliver! ๐ค
๐ค Next Steps
Congratulations! ๐ Youโve mastered the Liskov Substitution Principle!
Hereโs what to do next:
- ๐ป Practice with the notification system exercise
- ๐๏ธ Refactor existing inheritance hierarchies using LSP
- ๐ Move on to our next tutorial: Interface Segregation Principle
- ๐ Share your LSP implementations with the community!
Remember: Every great architect started with SOLID principles. Keep coding, keep learning, and most importantly, have fun! ๐
Happy coding! ๐๐โจ