OOP Interview Prep
SOLID Principles

Liskov Substitution Principle

Why Square Extending Rectangle Breaks Your Code

LinkedIn Hook

Here is a question that trips up senior developers in system design interviews:

"Can a Square extend a Rectangle?"

Mathematically, yes. In geometry, every square is a rectangle. But in object-oriented code that relationship breaks things. Quietly. The program compiles, tests pass, and then a consumer of your Rectangle class gets back results that make no sense.

That is the Liskov Substitution Principle violation in its most classic form. And once you truly understand it, you start spotting the same pattern everywhere — in inheritance hierarchies that "make sense on paper" but fail at runtime.

Read the full lesson with the violation, the diagnosis, and the correct fix → [link]

#OOP #SOLID #JavaScript #SoftwareDesign #InterviewPrep


Liskov Substitution Principle thumbnail


What You'll Learn

  • The precise definition of the Liskov Substitution Principle and what "behavioral subtyping" actually means
  • Why a Square extending a Rectangle is a textbook LSP violation despite being mathematically sound
  • How to detect LSP violations in any inheritance hierarchy using the substitutability test
  • Three runnable JavaScript examples: the violation, the diagnosis, and two correct fixes
  • How preconditions, postconditions, and invariants connect to LSP
  • The most common interview questions on LSP with complete answers

The Analogy That Makes It Click

Think about electrical outlets.

Every country outlet follows a contract: if you plug in a device rated for 230V/50Hz, the outlet must deliver 230V/50Hz. You do not check whether you are in Berlin or Vienna. You just plug in. The outlet is substitutable because it honors the same contract everywhere.

Now imagine a "special" outlet that looks identical but occasionally cuts voltage by half "to save energy." It is still an outlet. But it violates the contract. Code written to expect a standard outlet breaks in ways that are hard to trace, because the substitution looked valid from the outside.

That is LSP. A subclass that looks like its parent but secretly changes the contract is a broken outlet. Code that consumed the parent will silently misbehave when handed the subclass.

[INTERNAL-LINK: how inheritance creates these contracts → Lesson 4.1: Inheritance Basics]


What Is the Liskov Substitution Principle?

The Liskov Substitution Principle states that if S is a subtype of T, then objects of type T in a program may be replaced with objects of type S without altering any of the correct behavior of that program. Barbara Liskov introduced this principle in 1987. Robert Martin later included it as the L in SOLID.

The practical test is simple: every place your code uses a base class, you must be able to swap in any of its subclasses and get the same behavioral guarantees. Not just the same method names. The same contracts: the same preconditions, the same postconditions, the same invariants.

"Subclass compiles and runs" is not the bar. "Subclass does not break any consumer that was written against the parent" is the bar.

[INTERNAL-LINK: how SOLID principles relate to each other → Lesson 7.1: Single Responsibility Principle]


What Is the Rectangle and Square Problem?

The Rectangle/Square problem is the most cited LSP violation in software interviews. The setup is intuitive: a square is a special case of a rectangle where width equals height. So a developer models Square extends Rectangle. The class hierarchy matches real-world geometry. The code compiles cleanly.

The violation appears when you try to use a Square anywhere a Rectangle is expected. A Rectangle has two independent dimensions: you can set the width without affecting the height. A Square cannot honor that contract. Setting one dimension must always set the other. The postcondition that Rectangle guarantees ("width and height are independently configurable") is broken by Square.

[CHART: Comparison table - Rectangle contract vs Square actual behavior - manual data]


Code Example 1 — The LSP Violation

This example shows the classic Square extends Rectangle hierarchy and then demonstrates exactly where it breaks.

// Rectangle class — establishes a contract with two independent dimensions
class Rectangle {
  constructor(width, height) {
    this.width = width;
    this.height = height;
  }

  setWidth(width) {
    this.width = width;
  }

  setHeight(height) {
    this.height = height;
  }

  getArea() {
    return this.width * this.height;
  }

  describe() {
    return `Rectangle ${this.width}x${this.height} = area ${this.getArea()}`;
  }
}

// Square extends Rectangle — looks reasonable, is actually a violation
class Square extends Rectangle {
  constructor(size) {
    super(size, size);
  }

  // Square must keep both dimensions equal at all times
  // So setting one silently overrides the other
  setWidth(width) {
    this.width = width;
    this.height = width; // side effect that Rectangle never promised
  }

  setHeight(height) {
    this.width = height; // side effect that Rectangle never promised
    this.height = height;
  }
}

// This function is written against the Rectangle contract
// It should work correctly for any Rectangle or subtype of Rectangle
function resizeToLandscape(rect) {
  rect.setWidth(10);
  rect.setHeight(5);
  // We expect area = 10 * 5 = 50 for any Rectangle
  console.log(`Expected area: 50, Actual area: ${rect.getArea()}`);
}

const rect = new Rectangle(2, 2);
resizeToLandscape(rect);
// Output: Expected area: 50, Actual area: 50 — correct

const square = new Square(2);
resizeToLandscape(square);
// Output: Expected area: 50, Actual area: 25 — WRONG
// setWidth(10) set both to 10, then setHeight(5) set both to 5
// The function assumed independent dimensions — Square silently broke that

The consumer resizeToLandscape was written correctly against Rectangle's contract. It has no bug. The problem is that Square violates the postcondition that Rectangle established: after setWidth(10), the width will be 10 and the height will remain unchanged. Square cannot keep that promise.

[IMAGE: Flowchart showing resizeToLandscape function receiving a Rectangle (produces 50) vs a Square (produces 25) — search terms: "LSP violation rectangle square diagram"]


How to Detect an LSP Violation

The substitutability test is the cleanest diagnostic. Write a function that accepts the base type and relies on its documented contracts. Then pass in the subtype. If the results differ or an invariant breaks, the subtype violates LSP.

Three specific signals to look for:

Broken postconditions. The parent method guarantees a result. The child method returns something different or has side effects the parent never mentioned. Square.setWidth setting the height is a broken postcondition.

Strengthened preconditions. The parent accepts a wide range of inputs. The child throws exceptions or silently ignores inputs that the parent would have processed. Any consumer relying on the parent's tolerance will fail with the child.

Violated invariants. The parent maintains a certain state guarantee throughout its lifetime. The child breaks that guarantee. Rectangle maintains the invariant that width and height are independent values. Square breaks it.

[INTERNAL-LINK: invariants and class contracts → Lesson 2.1: Encapsulation]


Code Example 2 — Fix 1: Flatten the Hierarchy with a Shared Base

The first correct approach removes the parent-child relationship between Rectangle and Square entirely. Both become concrete classes that independently extend a shared abstract shape.

// Shared base — defines only the area contract, nothing about dimensions
class Shape {
  getArea() {
    throw new Error("getArea() must be implemented by subclass");
  }

  describe() {
    return `Shape with area ${this.getArea()}`;
  }
}

// Rectangle — owns its own dimension contract with no Square baggage
class Rectangle extends Shape {
  constructor(width, height) {
    super();
    this.width = width;
    this.height = height;
  }

  setWidth(width) {
    this.width = width; // only width changes — contract honored
  }

  setHeight(height) {
    this.height = height; // only height changes — contract honored
  }

  getArea() {
    return this.width * this.height;
  }

  describe() {
    return `Rectangle ${this.width}x${this.height} = area ${this.getArea()}`;
  }
}

// Square — owns its own single-dimension contract
class Square extends Shape {
  constructor(size) {
    super();
    this.size = size;
  }

  setSize(size) {
    this.size = size; // honest API — no pretending to have two dimensions
  }

  getArea() {
    return this.size * this.size;
  }

  describe() {
    return `Square ${this.size}x${this.size} = area ${this.getArea()}`;
  }
}

// resizeToLandscape only accepts Rectangle now — the type makes the contract explicit
function resizeToLandscape(rect) {
  rect.setWidth(10);
  rect.setHeight(5);
  console.log(`Expected area: 50, Actual area: ${rect.getArea()}`);
}

const rect = new Rectangle(2, 2);
resizeToLandscape(rect);
// Output: Expected area: 50, Actual area: 50 — correct

// You cannot pass a Square to resizeToLandscape now
// Square does not have setWidth or setHeight — the mismatch is explicit, not silent
const square = new Square(2);
square.setSize(7);
console.log(square.describe());
// Output: Square 7x7 = area 49

// Both work correctly as Shape references for area-only operations
const shapes = [new Rectangle(4, 6), new Square(5)];
shapes.forEach(s => console.log(s.describe()));
// Output:
// Rectangle 4x6 = area 24
// Square 5x5 = area 25

[UNIQUE INSIGHT]: The fix is not about making Square work as a Rectangle. The fix is recognizing that Square should never have been a Rectangle in the first place. The geometric "is-a" relationship does not map cleanly to a behavioral "is-a" relationship. LSP is about behavior, not classification. Modeling inheritance after real-world taxonomies without checking behavioral contracts is the root cause of most LSP violations in production code.


Code Example 3 — Fix 2: Immutable Shape Objects

A second valid approach is to make shapes immutable. If dimensions cannot change after construction, the problematic setWidth/setHeight contract disappears entirely. This is common in functional-leaning codebases.

// Immutable Rectangle — no setters, dimensions fixed at construction
class Rectangle {
  constructor(width, height) {
    // Store as private to enforce immutability
    this._width = width;
    this._height = height;
    Object.freeze(this); // prevent any property modification
  }

  get width() {
    return this._width;
  }

  get height() {
    return this._height;
  }

  // Returns a new Rectangle instead of mutating this one
  withWidth(width) {
    return new Rectangle(width, this._height);
  }

  withHeight(height) {
    return new Rectangle(this._width, height);
  }

  getArea() {
    return this._width * this._height;
  }

  describe() {
    return `Rectangle ${this._width}x${this._height} = area ${this.getArea()}`;
  }
}

// Immutable Square — size is fixed, no independent dimension setters
class Square extends Rectangle {
  constructor(size) {
    super(size, size);
  }

  // Square can safely override withWidth and withHeight
  // because both must return a Square (both dimensions change together)
  withWidth(size) {
    return new Square(size);
  }

  withHeight(size) {
    return new Square(size);
  }

  describe() {
    return `Square ${this._width}x${this._height} = area ${this.getArea()}`;
  }
}

// With immutable objects, the "resize" function must return a new value
function resizeToLandscape(rect) {
  const resized = rect.withWidth(10).withHeight(5);
  console.log(`Expected area: 50, Actual area: ${resized.getArea()}`);
  return resized;
}

const rect = new Rectangle(2, 2);
resizeToLandscape(rect);
// Output: Expected area: 50, Actual area: 50 — correct

// NOTE: The Square version still violates the spirit of "independent dimensions"
// withHeight(5) after withWidth(10) will produce Square(5), not a 10x5 shape
// This approach avoids silent mutation bugs but does not eliminate the conceptual mismatch
// Fix 1 (separate hierarchies) is the cleaner solution for dimension-independent shapes
const square = new Square(2);
resizeToLandscape(square);
// Output: Expected area: 50, Actual area: 25 — still wrong for this use case

// The correct use of immutable Square: treat it only as a Shape
const shapes = [new Rectangle(4, 6), new Square(5)];
shapes.forEach(s => console.log(s.describe()));
// Output:
// Rectangle 4x6 = area 24
// Square 5x5 = area 25

[PERSONAL EXPERIENCE]: In practice, the immutable approach resolves mutation-related LSP violations but does not fully eliminate the contract problem for shapes with independent dimensions. Teams that move to immutable value objects often discover that the "is-a" modeling question simply shifts from mutation to construction. Fix 1 — separate hierarchies under a shared base — remains the most explicit and maintainable solution for the Rectangle/Square case specifically.


Code Example 4 — LSP Violation in a Real-World Context

The Rectangle/Square case is the textbook example, but LSP violations appear frequently in everyday code. This example shows a common pattern: a read-only repository subtyping a read-write repository.

// Base repository — establishes a read-write contract
class UserRepository {
  constructor() {
    this.users = new Map();
  }

  save(user) {
    this.users.set(user.id, user);
    console.log(`Saved user ${user.id}`);
  }

  findById(id) {
    return this.users.get(id) || null;
  }

  delete(id) {
    this.users.delete(id);
    console.log(`Deleted user ${id}`);
  }

  findAll() {
    return Array.from(this.users.values());
  }
}

// LSP VIOLATION: ReadOnlyUserRepository extends UserRepository
// but cannot honor the save/delete contract
class ReadOnlyUserRepository extends UserRepository {
  constructor(initialUsers) {
    super();
    initialUsers.forEach(u => this.users.set(u.id, u));
  }

  // Throws an error where the parent would succeed — strengthens the precondition
  save(user) {
    throw new Error("This repository is read-only — save is not permitted");
  }

  // Throws an error where the parent would succeed — strengthens the precondition
  delete(id) {
    throw new Error("This repository is read-only — delete is not permitted");
  }
}

// Code written against the UserRepository contract
function transferUser(sourceRepo, targetRepo, userId) {
  const user = sourceRepo.findById(userId);
  if (user) {
    targetRepo.save(user); // expects this to work for any UserRepository
    sourceRepo.delete(userId);
  }
}

const writeRepo = new UserRepository();
const readOnlyRepo = new ReadOnlyUserRepository([
  { id: 1, name: "Alice" },
  { id: 2, name: "Bob" },
]);

writeRepo.save({ id: 10, name: "Carol" });
transferUser(writeRepo, writeRepo, 10);
// Works: save and delete both succeed

transferUser(readOnlyRepo, writeRepo, 1);
// Works: readOnlyRepo.findById works, writeRepo.save works, readOnlyRepo.delete throws
// Output: Error: This repository is read-only — delete is not permitted

// ---- CORRECT FIX: separate the read and write contracts ----

class ReadableRepository {
  findById(id) {
    throw new Error("findById must be implemented");
  }

  findAll() {
    throw new Error("findAll must be implemented");
  }
}

class WritableRepository extends ReadableRepository {
  save(entity) {
    throw new Error("save must be implemented");
  }

  delete(id) {
    throw new Error("delete must be implemented");
  }
}

// transferUser now accepts only WritableRepository — the type prevents misuse
function transferUserSafe(sourceRepo, targetRepo, userId) {
  // sourceRepo and targetRepo must both be WritableRepository
  const user = sourceRepo.findById(userId);
  if (user) {
    targetRepo.save(user);
    sourceRepo.delete(userId);
  }
}

// ReadOnly consumers only depend on ReadableRepository — no write contract to violate
function listAllUsers(repo) {
  // repo only needs to be ReadableRepository
  return repo.findAll();
}

Liskov Substitution Principle visual 1


Behavioral Subtyping Rules

LSP is formally described through three rules that a subtype must satisfy.

Rule 1 — Preconditions cannot be strengthened. If the parent method accepts any positive integer, the child method cannot restrict that to even numbers only. The subtype must accept at least as much as the parent. Consumers written for the parent will pass inputs the parent allows, and the child must not reject them.

Rule 2 — Postconditions cannot be weakened. If the parent method guarantees it returns a non-null value, the child cannot return null in some cases. Consumers that skip null checks because the parent never returned null will break.

Rule 3 — Invariants must be preserved. If the parent guarantees that a field always stays within a certain range, the child must maintain that guarantee. Breaking an invariant means the object's state is no longer predictable.

A fourth rule is often added: the history rule. The child must not introduce mutations that the parent's type did not allow. An immutable parent must not produce a mutable child.


Common Mistakes

  • Treating LSP as "can the subclass compile." LSP is not about compilation. It is about behavior. A subclass that compiles and passes unit tests for its own methods can still violate LSP if it breaks contracts that consumers relied on through the parent type. The test is always: does substituting the child for the parent change observable behavior in consumer code?

  • Modeling inheritance from real-world "is-a" relationships without checking behavioral contracts. Square is mathematically a rectangle. But Square cannot be a behavioral Rectangle if Rectangle guarantees independent dimensions. Real-world taxonomy and software behavioral contracts are different things. The geometry makes inheritance feel right. The behavior makes it wrong.

  • Throwing NotImplementedException or UnsupportedOperationException from inherited methods. If a child class inherits a method it cannot support and throws an error, it has strengthened the precondition from "any input is acceptable" to "this operation is never acceptable." Any code that called the parent method will now crash. This is one of the most common LSP violations in production code.

  • Confusing LSP with method overriding. Overriding a method is not automatically an LSP violation. Overriding is fine as long as the child honors the behavioral contract. An LSP violation is a specific kind of override where the child breaks the contract the parent established: stronger preconditions, weaker postconditions, or broken invariants.

  • Ignoring return type covariance and parameter contravariance. Formally, LSP allows return types to be narrowed (covariance) and parameter types to be widened (contravariance). Violating these rules creates subtle incompatibilities. In practice, JavaScript's dynamic typing hides these violations until runtime, which makes them harder to catch without TypeScript's type checking.


Interview Questions

Q: What does the Liskov Substitution Principle state in plain language?

If your code works correctly when given a base class object, it must continue to work correctly when given any subclass object in its place. The subclass can extend behavior but cannot break the contracts established by the parent: the same preconditions must be accepted, the same postconditions must be guaranteed, and the same invariants must be preserved throughout the object's lifetime.

Q: Why does Square extending Rectangle violate LSP?

Rectangle establishes a contract: width and height are independent. You can set one without affecting the other. Square cannot honor that contract because a square must always have equal sides. Any Square implementation of setWidth must also change the height, which is a side effect Rectangle never promised. Code written against Rectangle that calls setWidth and setHeight independently will produce wrong results when given a Square. The substitution is broken.

Q: How do you fix the Rectangle/Square LSP violation?

The cleanest fix is to flatten the hierarchy. Both Rectangle and Square extend a shared abstract Shape base that only defines contracts they both honor, such as getArea. Neither extends the other. Code that needs independent dimension control depends on Rectangle directly. Code that only needs area computation depends on Shape. The type system then prevents the invalid substitution at the call site rather than allowing it to fail silently at runtime.

Q: How do you detect an LSP violation in an existing codebase?

Write a function that accepts the base type and exercises its documented behavior. Pass in every subtype. Any case where the results differ from what the base type would have produced is a violation. More specifically, look for: methods in subclasses that throw errors the parent would not throw (strengthened preconditions), methods that return different results than the parent's postcondition promised, and methods that break invariants the parent guaranteed. Also look for subclasses that override methods with empty bodies or throw new Error("not supported").

Q: How does LSP relate to the Open/Closed Principle?

They are complementary. OCP says classes should be open for extension through inheritance or composition without modifying existing code. LSP says that extension through inheritance is only valid if the subtype honors the parent's behavioral contract. OCP tells you to extend, not modify. LSP tells you how to extend safely. A class that violates LSP breaks the assumption that OCP relies on: that substituting an extension for a base type is safe. Together they define what "extending without modifying" must actually mean.

[INTERNAL-LINK: how OCP sets up the problem LSP constrains → Lesson 7.2: Open/Closed Principle]


Quick Reference Cheat Sheet

LISKOV SUBSTITUTION PRINCIPLE — QUICK REFERENCE
---------------------------------------------------------------------------
Core Rule         If S extends T, any code using T must work
                  correctly with S substituted in — same behavior,
                  same contracts, no surprises

The Test          Write a consumer against the base type.
                  Swap in the subtype. Did behavior change?
                  If yes: LSP violation.

Three Rules       1. Preconditions cannot be STRENGTHENED
                     (subtype must accept at least what parent accepts)
                  2. Postconditions cannot be WEAKENED
                     (subtype must deliver at least what parent delivers)
                  3. Invariants must be PRESERVED
                     (subtype must maintain all parent state guarantees)
---------------------------------------------------------------------------

THE CLASSIC VIOLATION — Rectangle/Square
---------------------------------------------------------------------------
// WRONG: Square extends Rectangle
class Square extends Rectangle {
  setWidth(w) {
    this.width = w;
    this.height = w;   // side effect Rectangle never promised
  }
  setHeight(h) {
    this.width = h;    // side effect Rectangle never promised
    this.height = h;
  }
}

// Consumer written for Rectangle breaks with Square
function resize(rect) {
  rect.setWidth(10);
  rect.setHeight(5);
  // Expected: 50 — Actual with Square: 25
}

---------------------------------------------------------------------------
THE FIX — Flatten the hierarchy
---------------------------------------------------------------------------
class Shape {
  getArea() { ... }             // shared contract both can honor
}

class Rectangle extends Shape {
  setWidth(w)  { this.width = w; }    // independent — no side effects
  setHeight(h) { this.height = h; }   // independent — no side effects
  getArea()    { return this.width * this.height; }
}

class Square extends Shape {
  setSize(s)   { this.size = s; }     // honest single-dimension API
  getArea()    { return this.size * this.size; }
}

// Code that needs independent dimensions depends on Rectangle directly
// Code that only needs area depends on Shape — both subtypes are safe

---------------------------------------------------------------------------
VIOLATION SIGNALS — What to look for
---------------------------------------------------------------------------
  - Child method throws where parent would succeed
  - Child method ignores an argument the parent always used
  - Child method returns null where parent guaranteed non-null
  - Child requires extra setup steps the parent never needed
  - Child breaks an invariant (e.g., forces equal dimensions)
  - Tests pass per class but fail when base-type reference used

---------------------------------------------------------------------------
LSP vs OVERRIDING — Key distinction
---------------------------------------------------------------------------
  Overriding:    Child replaces parent behavior — fine on its own
  LSP violation: Child overrides in a way that breaks the parent's
                 behavioral contract — preconditions, postconditions,
                 or invariants are no longer honored

---------------------------------------------------------------------------
REAL-WORLD VIOLATION PATTERN — Read-only subtype
---------------------------------------------------------------------------
  // WRONG
  class ReadOnlyRepo extends UserRepository {
    save()   { throw new Error("read-only"); }   // strengthens precondition
    delete() { throw new Error("read-only"); }   // strengthens precondition
  }

  // CORRECT — separate read and write contracts
  class ReadableRepository  { findById() {...} findAll() {...} }
  class WritableRepository extends ReadableRepository {
    save() {...} delete() {...}
  }
  class ReadOnlyRepo extends ReadableRepository { ... } // no write contract

---------------------------------------------------------------------------
CONNECTION TO OTHER SOLID PRINCIPLES
---------------------------------------------------------------------------
  OCP: "Extend, don't modify"   LSP: "Extend only if you honor the contract"
  ISP: "Split fat interfaces"   LSP: "Don't inherit contracts you can't keep"
  DIP: "Depend on abstractions" LSP: "Those abstractions are safe only if LSP holds"
---------------------------------------------------------------------------

Liskov Substitution Principle visual 2


Previous: Lesson 7.2 - Open/Closed Principle Next: Lesson 7.4 - Interface Segregation Principle


This is Lesson 7.3 of the OOP Interview Prep Course — 8 chapters, 41 lessons.

On this page