Lesson 34 of 54

SOLID Principles in Practice

Liskov Substitution Principle (LSP)

The Liskov Substitution Principle states:

"If S is a subtype of T, then objects of type T may be replaced with objects of type S without altering any of the desirable properties of the program."

In simpler terms: subtypes must be substitutable for their base types without breaking anything.

If your code works with a Bird, it should work with any subclass of Bird. If it breaks when you pass an Ostrich, you've violated LSP.

The Classic Example: Square and Rectangle

This seems logical:

Typescript
class Rectangle {
  constructor(protected width: number, protected height: number) {}
  
  setWidth(w: number) { this.width = w; }
  setHeight(h: number) { this.height = h; }
  
  getArea(): number { return this.width * this.height; }
}

class Square extends Rectangle {
  constructor(side: number) {
    super(side, side);
  }
  
  setWidth(w: number) { 
    this.width = w; 
    this.height = w;  // Square must keep sides equal
  }
  
  setHeight(h: number) { 
    this.height = h;
    this.width = h;   // Square must keep sides equal
  }
}

A square is a rectangle, right? Mathematically, yes. In code, no.

Typescript
function testRectangle(rect: Rectangle) {
  rect.setWidth(5);
  rect.setHeight(4);
  console.assert(rect.getArea() === 20);  // Fails for Square!
}

testRectangle(new Rectangle(0, 0));  // Passes
testRectangle(new Square(0));        // Fails! Area is 16, not 20

The Square can't substitute for Rectangle because it changes the expected behavior of setWidth and setHeight.

LSP Violations in the Real World

Throwing Unexpected Exceptions

Typescript
interface Bird {
  fly(): void;
}

class Sparrow implements Bird {
  fly() { /* flaps wings */ }
}

class Penguin implements Bird {
  fly() { 
    throw new Error("Penguins can't fly!");  // LSP violation
  }
}

function makeBirdsFly(birds: Bird[]) {
  birds.forEach(b => b.fly());  // Breaks if any bird is a Penguin
}

Code that works with Bird expects fly() to work. Penguin breaks that expectation.

Weakening Postconditions

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

class OverdraftAccount extends BankAccount {
  withdraw(amount: number): number {
    this.balance -= amount;  // Allows negative balance
    return amount;
  }
}

BankAccount.withdraw guarantees you won't go negative. OverdraftAccount removes that guarantee. Code relying on that postcondition breaks.

Strengthening Preconditions

Typescript
class File {
  write(data: string): void {
    // Writes any data
  }
}

class SecureFile extends File {
  write(data: string): void {
    if (!this.isEncrypted(data)) {
      throw new Error('Data must be encrypted');  // Stricter requirement
    }
    super.write(data);
  }
}

File.write accepts any string. SecureFile.write demands encryption. Code that works with File breaks with SecureFile.

The Rules of Substitutability

1. Signature Compatibility

Subtypes must have compatible method signatures. (TypeScript enforces this at compile time.)

2. Preconditions Can't Be Strengthened

If the base class accepts certain inputs, the subclass must accept at least those inputs (possibly more).

Typescript
// Base: accepts numbers 0-100
// Valid subclass: accepts numbers 0-200 (weaker precondition)
// Invalid subclass: accepts only 0-50 (stronger precondition)

3. Postconditions Can't Be Weakened

If the base class guarantees certain outputs or states, the subclass must guarantee at least those (possibly more).

Typescript
// Base: returns positive numbers
// Valid subclass: returns positive even numbers (stronger postcondition)
// Invalid subclass: returns any number (weaker postcondition)

4. Invariants Must Be Preserved

If the base class maintains certain invariants (e.g., balance >= 0), subclasses must too.

5. History Constraint

Subclasses shouldn't allow state changes that the base class doesn't allow.

Fixing LSP Violations

Solution 1: Rethink the Hierarchy

The Square/Rectangle problem is best solved by not using inheritance:

Typescript
interface Shape {
  getArea(): number;
}

class Rectangle implements Shape {
  constructor(private width: number, private height: number) {}
  getArea() { return this.width * this.height; }
}

class Square implements Shape {
  constructor(private side: number) {}
  getArea() { return this.side * this.side; }
}

No inheritance, no substitution problem.

Solution 2: Use Composition

Instead of Penguin extends Bird, use composition:

Typescript
interface Animal {
  move(): void;
}

interface FlyingAbility {
  fly(): void;
}

class Sparrow implements Animal, FlyingAbility {
  move() { this.fly(); }
  fly() { /* flaps wings */ }
}

class Penguin implements Animal {
  move() { this.waddle(); }
  waddle() { /* waddles */ }
}

Solution 3: Segregate Interfaces

Split the interface so clients only depend on what they need:

Typescript
interface Walker {
  walk(): void;
}

interface Flyer {
  fly(): void;
}

class Sparrow implements Walker, Flyer {
  walk() { }
  fly() { }
}

class Penguin implements Walker {
  walk() { }
  // No fly() method - not a Flyer
}

// Code that needs flying only asks for Flyer
function conductFlightTest(bird: Flyer) {
  bird.fly();  // Only works with birds that can actually fly
}

Testing for LSP Compliance

Write tests against the base type, then run them with all subtypes:

Typescript
function testBankAccount(account: BankAccount) {
  account.deposit(100);
  account.withdraw(50);
  expect(account.getBalance()).toBe(50);
  
  expect(() => account.withdraw(100)).toThrow();  // Should fail if insufficient
}

// Run with all subtypes
testBankAccount(new BankAccount());
testBankAccount(new SavingsAccount());
testBankAccount(new CheckingAccount());

If any subtype fails these tests, you have an LSP violation.


Key insight: LSP is about behavioral compatibility. A subtype must honor the contracts (preconditions, postconditions, invariants) of its base type. If substituting a subtype breaks code that works with the base type, reconsider your inheritance hierarchy. Often, composition or interface segregation is the better solution.