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:
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.
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
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
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
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).
// 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).
// 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:
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:
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:
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:
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.