Intermediate16 min readSOLID Principleslive prototype

Liskov Substitution Principle (LSP)

The 'L' in SOLID: if your code works with a base type, it must keep working when you hand it any subtype — no surprises, no broken promises. Inheritance has to preserve behaviour, not just shape.

The idea

What it is

The one sentence to remember

If code works with a base type, it must keep working when given any subtype — no surprises.

The Liskov Substitution Principle (the L in SOLID) is about one promise: anywhere your code expects a base type, you should be able to drop in any subtype and the code keeps doing the right thing. The substitute behaves like what it claims to be. If swapping in a subclass quietly changes the result, throws an error the caller didn't expect, or does less than the base promised, you've broken LSP.

Think of a vending machine that takes any coin marked “1 dollar.” If someone slips in a fake that's the right size and weight but worth nothing, the machine still accepts it and your snack vanishes into a loss. The fake looked like a dollar (same shape) but didn't behave like one (same value). LSP says a subtype must be a real dollar — same shape and same behaviour — so the machine (your code) never gets a nasty surprise.

Barbara Liskov framed it in 1987 and it later became the third letter of SOLID. The key shift in thinking: inheritance isn't just “this class has the same fields and methods.” It's a contract. The subtype inherits the base's promises, and it must honour every one of them.

Mechanics

How it works

Two classic violation stories

The fastest way to feel LSP is to see it break. There are two stories every engineer should know by heart.

Story 1 — the Rectangle and the Square. In maths, a square is a rectangle, so it's tempting to write class Square extends Rectangle. A rectangle has independent setWidth and setHeight. But a square must keep all sides equal, so its setWidth also changes the height (and vice versa). Now picture a caller that was written for a plain rectangle:

the caller (written for a Rectangle) s.setWidth(5); s.setHeight(4); assert area == 20 Rectangle 5 × 4 area = 20 ✓ as promised Square — setWidth also sets height 4 × 4 area = 16 ✗ expected 20 setWidth(5) → 5×5, then setHeight(4) → 4×4 the width you set was lost
Same caller, two substitutes. The Rectangle keeps width and height independent, so area == 20 holds. The Square ties them together: setHeight(4) quietly clobbers the width you just set, so the box is 4 × 4 and area == 16. The caller did nothing wrong — the subtype broke a promise the base made. That's an LSP violation.

Story 2 — the Bird that can't fly. Put a fly() method on a base Bird. Most birds are fine. Then along comes Penguin extends Bird — penguins don't fly. To compile, you override fly() to throw new UnsupportedOperationException(). Now any code that loops over birds and calls fly() blows up the moment it meets a penguin. The base promised “all birds fly”; the subtype can't keep that promise, so it cheats by throwing.

The tell-tale smell: a 'throw new NotSupportedException' override

If a subclass overrides a method only to throw ("not supported here") or to do nothing at all, that's a degenerate override — a loud signal it doesn't truly satisfy the base's contract. The subtype removed behaviour the base promised. That's almost always an LSP violation, and a hint your inheritance tree is wrong.

The rule-of-thumb tests

You don't need formal logic to apply LSP. Run a candidate override through these four checks — a real subtype passes all of them:

  • Don't strengthen preconditions. A subtype must not demand more of the caller than the base did. If the base accepts any integer but the subtype rejects negatives, callers written for the base will suddenly fail.
  • Don't weaken postconditions. A subtype must deliver at least what the base promised to return or guarantee. If the base says “returns a sorted list,” the subtype can't hand back an unsorted one.
  • Don't throw new, unexpected exceptions. A subtype shouldn't throw error types the caller never agreed to handle — the Penguin.fly() trap. (Throwing the same kinds the base already documents is fine.)
  • Don't remove behaviour the base promised. No degenerate overrides — no throw new NotSupportedException, no silently doing nothing where the base did something real.

is-a vs behaves-like-a

A square is a rectangle in geometry, and a penguin is a bird in biology — the shapes match. But neither behaves like the base your code relies on. LSP is the discipline of asking the harder question: not “is X a kind of Y?” but “can X stand in for Y everywhere Y is used, and keep every promise Y made?” Inheritance must preserve the contract, not just the field-and-method shape.

Why this is really about the caller

LSP protects the code that uses the base type. That code was written against the base's promises and shouldn't need a single if (x instanceof Square) special-case to stay correct. When you find yourself adding type checks to handle one particular subclass, the subclass has already broken substitutability — and you're now paying for it in branches scattered across the codebase.

Interactive prototype

See it. Build it. Break it.

A sandboxed, hands-on simulation — no setup, no install. Play with it as you read.

About this simulation

A substitution test bench. The caller code is fixed and you only change which object you pass in. In the Rectangle vs Square scenario the caller runs setWidth(5); setHeight(4) and expects area == 20. Pass a Rectangle and it passes (green, area 20). Pass a Square and watch its width-set silently also change its height — the expectation breaks (red, area 16). In the Bird.fly() scenario a Sparrow flies fine but a Penguin throws. The fixed explain panel names the exact rule that was broken in plain words, and a Fixed design view shows the corrected model where every substitute passes. Only one note shows at a time — nothing scrolls away or grows the page.

Hands-on

Try these yourself

Open the prototype above, predict what happens, then verify.

try 01

Watch the Square break the promise

Open the prototype on the Rectangle vs Square scenario. The caller is fixed: setWidth(5); setHeight(4) expecting area == 20. First click Pass a Rectangle and confirm it lands green at 20. Now click Pass a Square and watch the animation — see setHeight(4) silently drag the width down with it, so the box collapses to 4 × 4 and the result turns red at 16. Read the explain panel: which exact rule did the Square violate?

try 02

Make the Penguin throw

Switch to the Bird.fly() scenario. Pass a Sparrow first — it flies, green check. Then pass a Penguin and watch the caller's fly() call blow up with a thrown exception (red). Notice that the caller code never changed; only the object you substituted did. The explain panel should name this as throwing a new, unexpected exception — a degenerate override.

try 03

See the fixed design pass for everyone

Open the Fixed design view. Here Square is no longer a subtype of Rectangle — both implement a small immutable Shape interface with just area(), and birds split into a Bird base and a separate FlyingBird capability. Run every substitute through the bench again: they all pass green. Note what changed — we replaced bad inheritance with a shared interface (and composition), so no substitute can break a promise it never made.

In practice

When to use it — and what trips people up

How to obey LSP — and spot when inheritance is wrong

Before you write extends or : public, ask: can this subtype keep every promise the base makes, in every method, with no special-casing by callers? If yes, inheritance is a good fit. If you can already imagine a method the subtype would have to weaken, throw out of, or no-op, stop — inheritance is the wrong tool here.

  • Reach for a shared interface when several types offer the same capability but don't share real behaviour. Rect and Sq both area(), but neither needs the other's setters — an immutable Shape interface fits perfectly.
  • Reach for composition when a type has a behaviour rather than is a kind of something. A Penguin has-a swim() ability; a Sparrow has-a fly() ability. Model the capability as a separate piece you compose in, not a method on a one-size base.
  • Make objects immutable when you can. Half of LSP traps (the Rectangle/Square one included) come from mutating setters. No setters, no setter-promise to break.
  • Watch the callers. If using a subtype forces if (x instanceof Subtype) checks anywhere, substitutability is already broken — let that be your alarm.

When the subtype can't honour the full contract

Prefer composition or a shared interface over inheritance whenever a subtype can't keep every promise of the base. “Favour composition over inheritance” is, in large part, just LSP advice in disguise — it steers you away from is-a trees that can't honour their contracts.

What it gives you

  • Callers stay simple and correct — code written against the base needs no instanceof checks or special cases for particular subtypes.
  • Subtypes become freely swappable, which is exactly what makes polymorphism, mocking, and dependency injection trustworthy.
  • Bugs surface as design questions early ("can this really stand in for the base?") instead of as runtime surprises in production.
  • It keeps inheritance trees honest — you only inherit where behaviour truly transfers, so the hierarchy actually means something.

Common mistakes

  • It takes discipline and judgement: the violations (preconditions, postconditions, hidden exceptions) are subtle and easy to miss in review.
  • Honouring the full contract can mean more types — separate interfaces or composed capabilities instead of one tidy base class.
  • Real-world is-a relationships (square/rectangle, penguin/bird) tempt you into inheritance that violates LSP, so intuition can mislead.
  • Languages give you little help — most won't warn you when an override strengthens a precondition or throws a new exception; the check stays manual.

Reference

Code & further reading

A minimal reference implementation and pointers worth bookmarking.

// ✗ VIOLATION: Square "is-a" Rectangle on paper, but doesn't behave like one.
class Rectangle {
  constructor(protected w = 0, protected h = 0) {}
  setWidth(w: number) { this.w = w; }
  setHeight(h: number) { this.h = h; }
  area() { return this.w * this.h; }
}

class Square extends Rectangle {
  setWidth(w: number) { this.w = w; this.h = w; }   // also changes height!
  setHeight(h: number) { this.w = h; this.h = h; }  // also changes width!
}

// A caller written for the BASE type:
function checkArea(r: Rectangle) {
  r.setWidth(5);
  r.setHeight(4);
  return r.area() === 20;   // promise: 5 × 4 == 20
}
checkArea(new Rectangle()); // true  ✓
checkArea(new Square());    // false ✗ — area is 16, the Square broke LSP

// ✓ FIX: don't inherit. Share an immutable interface; each shape computes its own area.
interface Shape { area(): number; }
class Rect implements Shape {
  constructor(private readonly w: number, private readonly h: number) {}
  area() { return this.w * this.h; }
}
class Sq implements Shape {
  constructor(private readonly side: number) {}
  area() { return this.side * this.side; }
}
// Now ANY Shape substitutes safely — there's no setter promise to break.

References & further reading

5 sources

Knowledge check

Did it land?

Quick questions, answers revealed on submit. Sign in to save your best score.

question 01 / 05

In one sentence, what does the Liskov Substitution Principle require?

question 02 / 05

A caller does setWidth(5); setHeight(4) and expects area == 20. Why does Square extends Rectangle break it?

question 03 / 05

You override a base method only to throw new UnsupportedOperationException() (like Penguin.fly()). What does this signal?

question 04 / 05

Which of these is NOT one of the LSP rule-of-thumb tests for a valid override?

question 05 / 05

Your Square can't honour Rectangle's independent-setter contract. What's the cleaner fix?

0/5 answered