Back to the path
Level 2 intermediate 25 min #solid#lsp#oop#inheritance#polymorphism#design-by-contract#interview

Liskov Substitution Principle: Behavioral Subtyping Done Right

The "L" in SOLID is the one most engineers can recite but few can apply. This lesson nails the precise contract-based definition, walks the canonical Rectangle/Square trap, codifies the precondition/postcondition/invariant rules (with the real-language caveats a senior interviewer probes), and shows how an LSP violation silently destroys polymorphism. You leave able to spot and refactor a violation live in an interview.

The principle, stated precisely

The Liskov Substitution Principle (LSP) is the “L” in SOLID. Most engineers paraphrase it as “a subclass should be substitutable for its base class.” True, but uselessly vague. Barbara Liskov first articulated the substitutability idea informally in her 1987 OOPSLA keynote “Data Abstraction and Hierarchy.” The crisp, formal version everyone quotes comes later, from Liskov & Wing’s 1994 paper “A Behavioral Notion of Subtyping”:

Let φ(x) be a property provable about objects x of type T. Then φ(y) should be true for objects y of type S where S is a subtype of T.

Translated for working engineers: any code written against a base type must continue to behave correctly when handed any subtype, without knowing which subtype it got. The caller holds a T reference, was written and tested against T’s contract, and must not be surprised.

The keyword is contract. Inheritance gives you syntactic substitutability for free — the compiler accepts a Square wherever a Rectangle is expected. LSP is about semantic (behavioral) substitutability: the subtype must honor the promises the base type made, not merely match its method signatures. This is why LSP is also called behavioral subtyping.

Key idea

LSP is a constraint on the subtype author, enforced for the benefit of base-type clients. If a client must write if (x instanceof Penguin) to avoid a bug, the hierarchy already violates LSP. Type checks against subtypes are the loudest LSP smell.

Why “is-a” is a lie

The classic OOP teaching — “use inheritance when there’s an is-a relationship” — is what gets people into trouble. A square is a rectangle in geometry. A penguin is a bird. An ostrich is a bird. These are all true statements about the real world and all potential LSP disasters, because LSP is not about taxonomy — it’s about substitutable behavior under the operations you actually expose.

The correct mental rephrase is: “Is S substitutable for T everywhere T is used, given the methods on T?” If Bird has a fly() method, then Penguin is not a behavioral subtype of Bird, regardless of biology.

The canonical violation: Rectangle / Square

Here is the trap in its purest form. Start with a mutable Rectangle.

        Rectangle
   +------------------+
   | - w: number      |
   | - h: number      |
   +------------------+
   | setWidth(w)      |
   | setHeight(h)     |
   | area(): number   |
   +------------------+
          ^
          | extends   <-- "a square IS-A rectangle"
   +------------------+
   |     Square       |
   | setWidth/Height  |  <-- overridden to keep sides equal
   +------------------+

A mathematician nods: every square is a rectangle. So Square extends Rectangle, and to preserve the invariant w == h, Square overrides the setters so that setting width also sets height.

// THE VIOLATION — do not ship this
class Rectangle {
constructor(protected w: number, protected h: number) {}
setWidth(w: number) { this.w = w; }
setHeight(h: number) { this.h = h; }
area(): number { return this.w * this.h; }
}

class Square extends Rectangle {
constructor(side: number) { super(side, side); }
// To preserve w == h, mutate both:
override setWidth(w: number) { this.w = w; this.h = w; }
override setHeight(h: number) { this.w = h; this.h = h; }
}

// Client written ONLY against Rectangle's contract:
function stretchAndCheck(r: Rectangle): number {
r.setWidth(5);
r.setHeight(4);
// Provable property of Rectangle: after these calls, area == 20.
return r.area();
}

console.log(stretchAndCheck(new Rectangle(1, 1))); // 20  -> correct
console.log(stretchAndCheck(new Square(1)));       // 16  -> WRONG: setHeight clobbered width

The stretchAndCheck function never mentions Square. It was written, reviewed, and tested against Rectangle’s contract, where “set width to 5, set height to 4” provably yields area 20. Pass it a Square and that proof collapses. The bug lives in Square, but it detonates inside code that has never heard of Square. That is the essence of an LSP violation: it breaks the client’s reasoning, at a distance.

Common pitfall

In C++ the violation only manifests through a Rectangle& or Rectangle*. If stretchAndCheck took Rectangle by value, passing a Square would slice it — copying only the Rectangle sub-object and discarding the overridden virtual behavior — so it would silently print 20 and mask the bug. Object slicing is itself an LSP-adjacent hazard: it makes a broken subtype look correct.

Common pitfall

Notice the root cause is mutability plus an invariant. Two immutable value types Rectangle and Square with no setters can coexist happily — there is no operation that distinguishes them by behavior. LSP violations almost always surface when a subtype tightens an invariant that the base type’s mutating operations are allowed to break.

The rules: preconditions, postconditions, invariants

Behavioral subtyping is governed by Design by Contract (Bertrand Meyer). For an overridden method S.m substituting for T.m, three rules must hold:

RuleBase T.mOverride S.m must…Direction
Preconditionsrequires Prequire the same or weaker (P or less)cannot strengthen
Postconditionsguarantees Qguarantee the same or stronger (Q or more)cannot weaken
Invariantsmaintains Imaintain at least Ipreserved

Plus two structural rules from Liskov & Wing (1994):

  • History rule — the subtype must not introduce state transitions the base type’s contract forbids. The classic violation is a mutable subtype of an immutable base: code holding the base reference assumes the object’s observable state never changes after construction, then the subtype mutates it out from under them.
  • Variance — in the theoretically sound rule, an override’s method parameters may be contravariant (accept the same or wider types) and return values covariant (return the same or narrower types).
Watch out

The contravariance rule is theory; mainstream languages do not implement it. C++ requires identical override signatures — a wider parameter type produces an unrelated overload that hides the base method, not an override (it won’t even compile with override). Java treats parameters invariantly. TypeScript treats method parameters bivariantly by default (unsound on purpose, tightened only under strictFunctionTypes for function-typed properties). Python is dynamically typed, so nothing is checked. Return-type covariance, by contrast, is supported: C++ allows a Base*-returning method to be overridden to return Derived*, and Java/TS allow narrowing return types. In an interview, state the rule, then immediately note that real languages only enforce the return half.

”No strengthening preconditions”

A precondition is what the caller must satisfy before calling. If the base accepts any integer but the subtype throws on negatives, you have strengthened the precondition — the caller, who only knew the base’s looser rule, now fails.

A subtle but crucial distinction: a precondition the base already enforces is not a strengthening when the subtype enforces it too. Below, CheckingAccount throwing on amount > balance is the base contract’s own implicit precondition (amount <= balance), so it is fine. FixedDepositAccount’s locked check is a brand-new precondition the base never declared — that is the strengthening. A sharp interviewer will probe exactly this difference.

abstract class Account {
// Contract: withdraw any amount up to the balance (implicit precond: amount <= balance).
abstract withdraw(amount: number): void;
}

class CheckingAccount extends Account {
constructor(private balance: number) { super(); }
withdraw(amount: number): void {
  // This is the BASE's implicit precondition, not a new one — OK.
  if (amount > this.balance) throw new Error("insufficient funds");
  this.balance -= amount;
}
}

// LSP VIOLATION: strengthens the precondition with a NEW rule.
class FixedDepositAccount extends Account {
constructor(private balance: number, private locked: boolean) { super(); }
withdraw(amount: number): void {
  if (this.locked) throw new Error("locked until maturity"); // NEW precondition
  this.balance -= amount;
}
}

// A batch payout loop written against Account breaks the moment
// a FixedDepositAccount appears, even for an amount within balance.

A payout loop iterating Account[] and calling withdraw for amounts it believes are valid (within balance) will throw unexpectedly on a FixedDepositAccount. The fix is to not model a fundamentally different capability through the same methodFixedDepositAccount either shouldn’t be an Account with an unconditional withdraw, or the base contract must explicitly allow withdrawal to be rejected (a weaker postcondition, which then every caller is forced to handle).

”No weakening postconditions”

A postcondition is what the method guarantees after it returns. If the base guarantees “returns a non-null, sorted list” and the subtype sometimes returns null or an unsorted list, the caller’s downstream code — which trusted the guarantee — breaks. The override may guarantee more (a tighter range, an additional invariant on the result) but never less.

Don’t conflate this with return-type covariance. Returning a narrower static type (e.g. base returns Animal, override returns Dog) is a typing/variance rule, enforced by the compiler. Strengthening a postcondition is a value/state guarantee about the returned object — e.g. base says “result is non-negative,” override says “result is strictly positive.” They often travel together but are different mechanisms: one is about the declared type, the other about the runtime value.

The refactor: model behavior, not taxonomy

There is no clever override that fixes Rectangle/Square — the hierarchy itself is wrong. The disciplined fixes, in order of preference:

  1. Composition / no inheritance. Make both immutable value types, or give Square a single side and a method to produce a Rectangle. They simply aren’t substitutable, so don’t pretend they are.
  2. Extract the real abstraction. What both genuinely share is being a shape with an area. Lift that into an interface and let neither inherit from the other.
// FIX: immutable value types + a shared read-only abstraction.
interface Shape {
area(): number;          // pure query, no setters => no invariant to break
}

class Rectangle implements Shape {
constructor(readonly w: number, readonly h: number) {}
area(): number { return this.w * this.h; }
withWidth(w: number): Rectangle { return new Rectangle(w, this.h); }
withHeight(h: number): Rectangle { return new Rectangle(this.w, h); }
}

class Square implements Shape {
constructor(readonly side: number) {}
area(): number { return this.side * this.side; }
withSide(s: number): Square { return new Square(s); }
}

// Any code over Shape is now safe; there are no mutating setters
// to violate, and neither type pretends to be the other.
function totalArea(shapes: Shape[]): number {
return shapes.reduce((sum, s) => sum + s.area(), 0);
}

console.log(totalArea([new Rectangle(5, 4), new Square(3)])); // 29

By removing the mutating setters and the shared mutable invariant, the conflict evaporates. Shape exposes only a query (area) that both types honor identically in spirit, so substitution is safe. The lesson: when a subtype can’t honor the base’s contract, the relationship is composition or a sibling abstraction, not inheritance.

Tip

A fast litmus test for any proposed override: “Could I delete this subtype and inline the base’s behavior in a test, and would the test still describe what this subtype does?” If the subtype throws where the base wouldn’t, returns less, or ignores arguments the base respects, it fails LSP.

How violating LSP breaks polymorphism — and everything downstream

Polymorphism is the entire payoff of subtyping: write process(Shape) once, run it over every shape forever. LSP is the precondition that makes polymorphism sound. When a subtype violates the contract, callers defend themselves the only way they can — by reintroducing type checks:

function render(s: Bird) {
  if (s instanceof Penguin)       // <-- the smell
    handleFlightless(s);
  else
    s.fly();
}

Each such instanceof/isinstance/dynamic_cast ladder is an admission that the polymorphism is broken. It also reverses your dependency direction: the base-type client now depends on the concrete subtypes, defeating OCP (you must edit render for every new subtype) and DIP (high-level code now knows low-level types).

Relationship to the other SOLID principles

  • OCP (Open/Closed): LSP is the enabler of OCP. You can only extend a system by adding subtypes without modifying existing code if those subtypes are substitutable. An LSP violation forces modification (a new if branch), breaking OCP.
  • DIP (Dependency Inversion): DIP says depend on abstractions. That only pays off if the abstraction’s contract is honored by every implementation — i.e., LSP holds. A leaky abstraction makes the inverted dependency a lie.
  • ISP (Interface Segregation): A frequent cure for an LSP violation is to split a fat interface. If Bird mixed fly() and eat(), segregating into Flyer and Eater lets Penguin implement only Eater — no violation. ISP removes the methods a subtype can’t honor.

Relationship to design patterns

  • Strategy / Template Method are only safe because each concrete strategy/step is a true behavioral subtype; an LSP-violating step silently corrupts the algorithm that orchestrates it.
  • Decorator is the strongest LSP discipline you’ll meet: a decorator must be substitutable for the component it wraps, or the wrapping chain breaks. A decorator that changes the contract — e.g. a BufferedStream that quietly drops bytes, or a logging decorator that swallows exceptions the inner component would throw — makes wrapping unsafe, because the client holds the component interface and never knows a decorator is present.
  • Null Object is a deliberate LSP-respecting substitute: it implements the full interface with do-nothing-but-valid behavior, precisely so clients never need a null check (an instanceof-style smell).

The interview signal

Reciting “subtypes should be substitutable” earns nothing — every candidate says it. The signal that you understand LSP shows up in three moves:

  1. You name the contract, not the taxonomy. When asked “is a Square a Rectangle?” the strong answer is “Syntactically yes, behaviorally no — under mutable setters a Square can’t honor Rectangle’s area contract, so I wouldn’t inherit.”
  2. You treat instanceof ladders as a diagnosis. Spotting a type-check ladder and saying “this is an LSP/OCP smell; let me push the behavior into the type” is a senior reflex.
  3. You know the precondition/postcondition directions cold — weaken inputs, strengthen outputs — and you know the variance caveat (real languages enforce only return-type covariance). That last nuance separates SSE candidates from mid-level ones.

In an interview, when you introduce any inheritance, say out loud: “Let me check substitutability — does every caller of the base still get correct behavior with this subtype?” That single sentence demonstrates the principle better than any definition.

Assessment

Pass mark: 70% on the concept questions unlocks the next lesson.

1. In the Rectangle/Square violation, where does the bug actually surface, and what is the root enabling condition?

2. Which of the following are TRUE about the contravariant-method-parameter rule as it applies to mainstream languages? (select all)

3. A CheckingAccount.withdraw throws when amount > balance; a FixedDepositAccount.withdraw additionally throws when the account is locked. Which is the LSP precondition violation, and why?

4. Why is returning a narrower static type (covariant return) NOT the same thing as strengthening a postcondition?

Design problem 5

You are given a logistics system with a base class Vehicle exposing move(distance: number): void and refuel(liters: number): void, plus current state getters. A new ElectricTruck subtype is requested. It cannot be refueled with liters (it charges in kWh), and calling refuel on it currently throws UnsupportedOperationException. A dispatch loop iterates Vehicle[] and periodically calls refuel. Diagnose the LSP problem precisely and redesign the hierarchy so the dispatch loop is safe and open to new vehicle types. Provide a brief class/interface sketch in any one language.