Solid
03 / 05

Liskov Substitution Principle

Liskov Substitution Principle (LSP)

"Objects of a superclass should be replaceable with objects of its subclasses without breaking the application."

The Liskov Substitution Principle means that if class B is a subtype of class A, we should be able to replace A with B without disrupting the behavior of our program.

The Problem: Violating LSP

// ❌ BAD: Square violates LSP
class Rectangle {
  constructor(
    protected width: number,
    protected height: number
  ) {}

  setWidth(width: number): void {
    this.width = width;
  }

  setHeight(height: number): void {
    this.height = height;
  }

  getArea(): number {
    return this.width * this.height;
  }
}

class Square extends Rectangle {
  constructor(side: number) {
    super(side, side);
  }

  // Problem: Square overrides behavior in unexpected way
  override setWidth(width: number): void {
    this.width = width;
    this.height = width; // Must keep square property
  }

  override setHeight(height: number): void {
    this.width = height; // Must keep square property
    this.height = height;
  }
}

// This function works correctly with Rectangle
function testRectangle(rect: Rectangle): void {
  rect.setWidth(5);
  rect.setHeight(4);
  
  console.log(`Expected area: 20`);
  console.log(`Actual area: ${rect.getArea()}`);
  
  // Expected: 20, but with Square it will be 16!
}

testRectangle(new Rectangle(0, 0)); // ✓ Works: Area = 20
testRectangle(new Square(0));       // ✗ Breaks: Area = 16

// Problem: Substituting Rectangle with Square breaks expected behavior!

The Solution: Applying LSP

// ✅ GOOD: Use composition or separate interfaces

// Option 1: Separate hierarchies
interface Shape {
  getArea(): number;
  getPerimeter(): number;
}

class Rectangle implements Shape {
  constructor(
    private width: number,
    private height: number
  ) {}

  setWidth(width: number): void {
    this.width = width;
  }

  setHeight(height: number): void {
    this.height = height;
  }

  getArea(): number {
    return this.width * this.height;
  }

  getPerimeter(): number {
    return 2 * (this.width + this.height);
  }
}

class Square implements Shape {
  constructor(private side: number) {}

  setSide(side: number): void {
    this.side = side;
  }

  getArea(): number {
    return this.side ** 2;
  }

  getPerimeter(): number {
    return 4 * this.side;
  }
}

// Now both work correctly with the Shape interface
function displayShapeInfo(shape: Shape): void {
  console.log(`Area: ${shape.getArea()}`);
  console.log(`Perimeter: ${shape.getPerimeter()}`);
}

displayShapeInfo(new Rectangle(5, 4)); // ✓ Works correctly
displayShapeInfo(new Square(5));       // ✓ Works correctly

Real-World Example: Birds

// ❌ BAD: Penguin can't fly, violates LSP
class Bird {
  fly(): void {
    console.log("Flying in the sky");
  }

  eat(): void {
    console.log("Eating food");
  }
}

class Sparrow extends Bird {
  // Inherits fly() - works fine
}

class Penguin extends Bird {
  // Problem: Penguins can't fly!
  override fly(): void {
    throw new Error("Penguins cannot fly");
  }
}

function makeBirdFly(bird: Bird): void {
  bird.fly(); // Works with Sparrow, crashes with Penguin!
}

// ✅ GOOD: Use proper interfaces
interface Animal {
  eat(): void;
  move(): void;
}

interface Flyable {
  fly(): void;
}

interface Swimmable {
  swim(): void;
}

class Sparrow implements Animal, Flyable {
  eat(): void {
    console.log("Sparrow eating seeds");
  }

  move(): void {
    this.fly();
  }

  fly(): void {
    console.log("Sparrow flying");
  }
}

class Penguin implements Animal, Swimmable {
  eat(): void {
    console.log("Penguin eating fish");
  }

  move(): void {
    this.swim();
  }

  swim(): void {
    console.log("Penguin swimming");
  }
}

class Duck implements Animal, Flyable, Swimmable {
  eat(): void {
    console.log("Duck eating");
  }

  move(): void {
    this.fly();
  }

  fly(): void {
    console.log("Duck flying");
  }

  swim(): void {
    console.log("Duck swimming");
  }
}

// Now we can work with appropriate interfaces
function makeFly(bird: Flyable): void {
  bird.fly(); // Only accepts birds that can fly
}

function makeSwim(animal: Swimmable): void {
  animal.swim(); // Only accepts animals that can swim
}

makeFly(new Sparrow());  // ✓ Works
makeFly(new Duck());     // ✓ Works
// makeFly(new Penguin()); // ✓ Compile error - Penguin doesn't implement Flyable

makeSwim(new Penguin()); // ✓ Works
makeSwim(new Duck());    // ✓ Works

LSP and Preconditions/Postconditions

Subclasses must not strengthen preconditions or weaken postconditions.

// ❌ BAD: Subclass strengthens preconditions
class Account {
  withdraw(amount: number): void {
    // Precondition: amount > 0
    if (amount <= 0) {
      throw new Error("Amount must be positive");
    }
    console.log(`Withdrew $${amount}`);
  }
}

class PremiumAccount extends Account {
  override withdraw(amount: number): void {
    // Strengthened precondition: amount > 0 AND amount <= 1000
    if (amount <= 0 || amount > 1000) {
      throw new Error("Amount must be between $0 and $1000");
    }
    console.log(`Withdrew $${amount}`);
  }
}

function processWithdrawal(account: Account): void {
  account.withdraw(5000); // Works with Account, fails with PremiumAccount
}

// ✅ GOOD: Subclass maintains or weakens preconditions
class Account {
  protected balance: number = 10000;

  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}. Balance: $${this.balance}`);
  }
}

class PremiumAccount extends Account {
  private overdraftLimit: number = 5000;

  override withdraw(amount: number): void {
    // Same or weaker precondition (allows overdraft)
    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}. Balance: $${this.balance}`);
  }
}

function processWithdrawal(account: Account): void {
  account.withdraw(500); // Works with both Account and PremiumAccount
}

Key Rules for LSP

  • Subclasses must accept the same input parameters as parent class

  • Subclasses must return the same types (or more specific) as parent class

  • Subclasses must not throw new exceptions parent doesn't throw

  • Subclasses must maintain class invariants

  • Subclasses should not require callers to have additional knowledge

Keep your own version of these notes — editable, searchable, and organised by your stack.

Start free