Skip to content

9.3.7 — Liskov Substitution

Here is code that compiles, passes its types, and is wrong.

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

class Square extends Rectangle {                       // (1) mathematically true
  override setWidth(w: number): void {
    this.width = w;
    this.height = w;                                   // (2) keeps the sides equal
  }
  override setHeight(h: number): void {
    this.width = h;
    this.height = h;
  }
}

Line (1) looks unarguable. Every school textbook says a square is a rectangle. Line (2) is the only way a square can stay a square when you change one side.

Now here is a function written months earlier by someone who had never heard of Square:

typescript
function resizeToFourByTen(r: Rectangle): void {
  r.setWidth(10);
  r.setHeight(4);
  console.log(r.area());        // expects 40
}

resizeToFourByTen(new Rectangle(1, 1));   // → 40   ✓
resizeToFourByTen(new Square(1));         // → 16   ✗

Nothing threw. Nothing failed to compile. The function did exactly what it was written to do, and got a wrong answer because the object it was handed did not behave the way every Rectangle before it had behaved.

That is a Liskov violation, and it is worth being precise about who was at fault. resizeToFourByTen is not badly written — it relies on something every rectangle has always done, which is that setting the width leaves the height alone. Square broke that, quietly, for every caller that already existed.

1. What the principle says

Barbara Liskov's formulation is precise and, on first reading, unfriendly. Here it is in plain words:

If code works with a type, it must keep working when handed any subtype, without knowing it happened.

The last clause is the whole principle. Not "it compiles". Not "it does not crash". The caller must not need to know. The moment a caller has to check what it actually got — if (x instanceof Square) — substitution has failed, and every benefit of polymorphism (9.2.5) is gone with it.

The reason this principle exists as a separate rule is that the compiler cannot check it. TypeScript verified that Square has the right methods with the right types. It has no way to know that setWidth was expected to leave the height alone, because that expectation was never written down anywhere. It lived in the heads of everyone who used Rectangle.

So Liskov is about the part of a contract that types cannot express: what the code promises to do, not just what shapes go in and out.

2. The four rules that make it checkable

"Behaves correctly" is too vague to review. These four turn it into something you can actually check, and each one has a plain reading.

Rule 1 — Do not demand more from the caller. If the parent accepts any positive number, the child cannot start rejecting numbers above 100. A caller that was passing 500 happily now gets an exception from code that used to work.

typescript
class Notifier {
  send(message: string): void { /* any message */ }
}
class SmsNotifier extends Notifier {
  override send(message: string): void {
    if (message.length > 160) throw new Error("too long");   // demands more
  }
}

Every existing caller of Notifier.send was safe passing a long message. Hand one an SmsNotifier and it breaks. The fix is not to relax the check — the 160-character limit is real. The fix is that SMS is not a drop-in Notifier; either the interface promises less from the start (it may truncate, and says so), or SMS is chosen deliberately rather than substituted silently.

Rule 2 — Do not promise less on the way out. If the parent guarantees it returns a non-empty list, the child cannot return an empty one. Callers wrote results[0] on the strength of that promise.

Rule 3 — Do not break the things that were always true. If Rectangle guaranteed that width and height move independently, Square cannot break it. If Account guaranteed the balance never goes negative, no subclass may allow it. These are the invariants from 9.2.2, and a subclass inherits the obligation to keep them.

Rule 4 — Do not throw new kinds of failure. If the parent's method never threw, a child that throws a network timeout has broken every caller that reasonably had no try. This one bites hardest when a subclass replaces local work with a remote call.

A useful compression of all four, worth memorising because it is what an interviewer wants to hear:

A subtype may accept more and promise more. It may never accept less or promise less.

3. How the violations actually show up

The Square example is the teaching case. Here is what it looks like in code you will really meet.

Throwing on a method you did not want. This is the most common one:

typescript
class ReadOnlyOrderList implements OrderList {
  add(order: Order): void {
    throw new Error("this list is read-only");    
  }
}

Any function that takes an OrderList and calls add now fails on this one. The type said it could add; the object disagrees. The honest fix is to split the interface — a ReadableOrderList that only reads, and a separate one that also writes — which is exactly the Interface Segregation Principle (9.3.8), and it is a good example of the principles being one problem seen from two sides.

Silently doing nothing. Worse than throwing, because nothing surfaces:

typescript
class NoOpAuditLog implements AuditLog {
  record(event: AuditEvent): void { /* deliberately empty */ }
}

If this ends up wired into production instead of a test, the compliance team discovers there is no audit trail, months later, during an audit. Silence is a behaviour, and it is a violation when callers were promised a record.

Changing the meaning of a return value. The parent returns null for "not found"; the child returns an empty object with default fields. Callers checking if (result === null) now treat a missing customer as a real one named "".

Strengthening a precondition through configuration. The parent works with any file size. The child works only under 5 MB because of the vendor behind it. Nothing in either type says so, and the failure arrives with the first large upload in production.

4. Why the Square is not a Rectangle

It is worth resolving this properly, because the answer generalises to every modelling decision you will make.

In mathematics, a square is a rectangle. That is true and irrelevant here.

In code, inheritance is not about categories. It is about substitutability of behaviour. The question is never "is a square a kind of rectangle?" It is "can every piece of code written for a rectangle keep working when handed a square?"

The answer is no, and the reason is precise: Rectangle has a mutable width and height that move independently. That independence is part of what a rectangle promises, even though nobody wrote it down. A square cannot keep that promise while remaining a square.

Notice what happens when you remove the mutability:

typescript
class Rectangle {
  constructor(readonly width: number, readonly height: number) {}   // (1) immutable
  area(): number { return this.width * this.height; }
  withWidth(w: number): Rectangle { return new Rectangle(w, this.height); }   // (2)
}
class Square extends Rectangle {
  constructor(side: number) { super(side, side); }                  // (3) fine
}

Line (1) makes the sides fixed at construction. Line (2) returns a new rectangle rather than changing this one, so nothing a caller holds can change underneath them. Line (3) is now a perfectly good subtype: a square is a rectangle whose sides happen to be equal, and no caller can break it because no caller can modify it.

The general lesson, which is worth more than the example: most Liskov violations come from mutation. Immutable types are far easier to substitute correctly, because there are no state changes for a subtype to interfere with. That is the same argument value objects made in 9.2.1 section 4, arriving from a different direction.

5. Applying it in review

Four questions that catch most violations before they ship.

Does any implementation throw for a method the interface offers? If yes, the interface promises something one implementation cannot do. Split it.

Does any caller check which implementation it has? instanceof inside your own domain logic is the loudest possible signal — the code is compensating for a substitution that does not really work (9.2.5 section 7).

Is there a rule that everyone knows and nobody wrote down? "Width and height are independent." "This never throws." "This always returns at least one row." Write these into the interface's documentation, because an unwritten promise is a promise somebody will break.

Does a subclass override a method to do less? Less work, fewer effects, an ignored argument. That is refused bequest (9.2.4 section 4), and it means the relationship was wrong — the class wanted composition rather than inheritance.

And one design habit that prevents most of it: write the interface from the caller's needs, not from an implementation's capabilities. An interface that only promises what every implementation can genuinely deliver cannot be violated, because there is nothing to violate.

6. Interview calibration

The forty-second answer: "If code works with a type, it has to keep working when handed a subtype, without knowing it happened. The compiler cannot check this, because it is about behaviour rather than shape — the classic example is Square extends Rectangle, where setting the width also changes the height, so a function that sets width and height and expects the two to be independent gets a wrong answer with no error. In practice I check four things: does the subtype demand more from the caller, promise less on the way out, break something that was always true, or throw a new kind of failure. The most common real-world version is an implementation that throws on a method it does not support, and that usually means the interface was too wide, so the fix is to split it."

Follow-up to expect: "how would you fix Square and Rectangle?" The strong answer is that you make the type immutable, at which point the substitution is genuinely safe, and you note that most Liskov violations trace back to mutable state.

Recall

  • If code works with a type, it must keep working when handed a subtype, without knowing it happened. The compiler cannot check this, because it is about behaviour, not shape.
  • The four checkable rules: do not demand more from the caller, do not promise less on the way out, do not break what was always true, do not throw new kinds of failure. Compressed: a subtype may accept more and promise more, never less.
  • Real-world violations: throwing on a method you did not want (fix by splitting the interface — this is Interface Segregation seen from the other side), silently doing nothing, changing the meaning of a return value, and quietly strengthening a limit such as a maximum file size.
  • Square extends Rectangle fails because a mutable rectangle promises that width and height move independently, and a square cannot keep that promise. Make the type immutable and the substitution becomes safe — most Liskov violations come from mutation.
  • Review questions: does any implementation throw for a method the interface offers; does any caller use instanceof on your own types; is there a rule everyone knows and nobody wrote down; does a subclass override to do less?
  • Prevention: write the interface from the caller's needs, promising only what every implementation can genuinely deliver.

Self-test: State the principle including the clause about the caller not knowing. Why can the type checker not catch a violation? Give the four rules and the one-line compression. Explain precisely why a square is not a rectangle in code, and what single change fixes it. What does a NoOpAuditLog violate, and how would you find out?

Quiz Bank

InterviewExplain the Liskov Substitution Principle and why Square extends Rectangle breaks it.

The principle: if a piece of code works with a type, it must keep working when it is handed any subtype, without knowing that it happened. The last clause is the whole point — the moment a caller has to check what it really got, substitution has failed and polymorphism has bought nothing.

Why it needs to be stated at all: the compiler cannot check it. Types describe shapes — which methods exist, what goes in, what comes out. Liskov is about behaviour, which is the part of the contract types cannot express.

The Square example, precisely. Rectangle has a mutable width and height, and every caller has always relied on an unwritten promise that setting one leaves the other alone. Square cannot keep that promise and remain a square, so its setWidth also changes the height. A function written months earlier that sets width to 10, sets height to 4, and expects an area of 40, silently gets 16. Nothing threw and nothing failed to compile. The function was not badly written; the subtype broke a promise it never declared.

Note who is at fault, because interviewers probe this. Not the caller, and not really the author of Square either — the fault is in the modelling. Inheritance in code is about substitutable behaviour, not about real-world categories. A square is a rectangle in geometry and is not a subtype of a mutable Rectangle in code.

The fix, which shows the general lesson. Make the type immutable: fix width and height at construction, and have any "change" return a new object. Now Square is a perfectly good subtype, because there is no state change for it to interfere with. Most Liskov violations trace back to mutable state, which is one of the strongest practical arguments for immutable value objects.

The version you will actually meet at work is not geometry. It is an implementation that throws "not supported" for a method the interface offers, or one that silently does nothing. Both mean the interface promised more than every implementation can deliver, and the fix is to narrow or split it.