Skip to main content

What is Liskov Substitution Principle (LSP)?

Liskov Substitution Principle (LSP) says that any object of a subclass must be replaceable for an object of its base class without the program breaking or producing wrong results.

Theory

TL;DR

  • Subtypes must honor the base class behavioral contract, not just match its method signatures
  • Analogy: if your "duck" swims and quacks normally, but calling fly() makes it behave like a chicken, you have an LSP violation
  • Three formal rules: subtypes can't strengthen preconditions (require more), can't weaken postconditions (deliver less), must preserve invariants
  • Decision rule: swap every base class instance with the subtype and run tests. If anything breaks, redesign

Quick example

javascript
class Rectangle { constructor() { this.width = 0; this.height = 0; } setWidth(w) { this.width = w; } setHeight(h) { this.height = h; } getArea() { return this.width * this.height; } } class Square extends Rectangle { setWidth(w) { this.width = this.height = w; } // couples both dimensions setHeight(h) { this.width = this.height = h; } } function useShape(shape) { shape.setWidth(5); shape.setHeight(4); return shape.getArea(); // caller expects 20 } console.log(useShape(new Rectangle())); // 20 ✅ console.log(useShape(new Square())); // 25 ❌ LSP violation

Rectangle has one key invariant: setWidth doesn't touch height, and setHeight doesn't touch width. Square breaks that by coupling them. No error is thrown. The code returns 25 instead of 20. That's what makes LSP bugs painful to track down.

Why "is-a" is not enough

Geometrically, a square is a rectangle. But in code, Square is not a substitutable Rectangle. That gap is exactly what LSP formalizes.

"Is-a" describes the real world. LSP describes behavioral compatibility at runtime. A class can satisfy the semantic relationship and still violate the behavioral contract.

The formal version, from Barbara Liskov's original 1987 paper: 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 the correctness of that program. Three rules follow.

Preconditions (what the caller must provide) can only stay the same or get weaker in a subtype. Postconditions (what the method guarantees on return) can only stay the same or get stronger. Invariants (truths about the object that always hold) must be preserved.

When to check for LSP violations

  • Every time you write extends on a class that already has clients
  • When a function accepts a base type parameter and you're adding a new subtype
  • When building a library: if you expose a base type, all implementations must substitute cleanly
  • Refactoring test: replace every base instance with your subtype across the codebase and run the full suite

How TypeScript handles this

TypeScript's structural type system checks method signatures at compile time. It will reject a subtype that narrows a return type in an incompatible way or accepts fewer parameters. But it won't catch behavioral invariant violations.

Both Rectangle and Square have identical signatures: setWidth(w: number), setHeight(h: number), getArea(): number. TypeScript sees two structurally compatible types and says nothing. The broken invariant is invisible to the type checker. It only appears at runtime as a wrong number.

In plain JavaScript there are no compile-time checks at all. LSP violations surface as bugs or silent wrong results. Tests are the only safety net. I've watched this exact Rectangle/Square pattern ship to a production dashboard where calculated areas were rendering incorrectly for months, and no tooling gave a single warning.

Common mistakes

Mistake 1: Overriding to mutate unrelated state

typescript
class BankAccount { balance = 0; deposit(amount: number) { this.balance += amount; } } class SavingsAccount extends BankAccount { deposit(amount: number) { super.deposit(amount); this.feeAccount.charge(1); // mutates external state clients don't know about } }

Clients expect deposit to only affect balance. Secretly charging a fee account breaks the postcondition. The fix: extract fee logic into a decorator or a separate service method.

Mistake 2: Throwing where the base handled gracefully

typescript
class Parser { parse(input: string | null) { return input || ''; } } class StrictParser extends Parser { parse(input: string | null) { if (!input) throw new Error('Input is required'); return input; } }

The base guarantees "always returns a string." StrictParser may throw instead. Any caller without a try/catch will break. If you need strictness, make the base strict too, or model failure with a Result<string, Error> type.

Mistake 3: Strengthening preconditions in a subtype

typescript
class BaseNotifier { notify(payload: { id: string }): void { console.log(`Notifying ${payload.id}`); } } class EmailNotifier extends BaseNotifier { notify(payload: { id: string }): void { if (!('email' in payload)) throw new Error('Missing email'); // ... } }

Base contract: accept anything with id. EmailNotifier demands email too. That's a stronger precondition. Any caller passing { id: '1' } works with BaseNotifier but crashes on EmailNotifier. The fix: move the requirement into the generic type parameter so TypeScript enforces it at compile time.

Mistake 4: Changing exception types

If the base throws ValidationError for bad input and your subtype throws TypeError, catch blocks written for ValidationError will miss it entirely. Subtypes must throw exceptions compatible with what the base contract promises.

Real-world usage

  • React: React.Component<Props> subtypes must accept all declared props. A child component that requires an extra prop the parent doesn't pass breaks LSP for every parent using that component.
  • Express: RequestHandler subtypes can't add a required req.user precondition without breaking every route handler that doesn't set it.
  • Node.js streams: Custom Readable implementations must honor the read(size?) contract and return Buffer or null. Returning anything else breaks all stream consumers.
  • Redux: Reducers treat actions as AnyAction. A reducer that throws on actions missing a specific field violates the base contract.
  • Vs. alternatives: if LSP is hard to satisfy through inheritance, composition or interfaces are usually the cleaner path.

Follow-up questions

Q: Why does Square violating Rectangle count as an LSP violation formally?
A: Rectangle has the invariant that setWidth(5) leaves height unchanged. Square sets both to 5, breaking that invariant. Clients relying on independent dimensions get wrong results with no error thrown.

Q: How is LSP different from the "is-a" relationship?
A: "Is-a" is semantic: a square is a rectangle in geometry. LSP is behavioral: can a Square replace a Rectangle in every context without changing what the code does? Here it can't. Semantic truth doesn't guarantee behavioral substitutability.

Q: TypeScript enforces structural typing. Does that mean LSP is guaranteed?
A: No. TypeScript checks signatures, not behavior. Square and Rectangle have identical signatures, so the compiler is satisfied. The invariant violation happens at runtime. Tests are what actually enforce LSP in practice.

Q: Give a precondition and postcondition example.
A: Base method divide(a: number, b: number = 1) has precondition: b can be omitted. A subtype that requires b to always be explicitly provided strengthens that precondition, which is a violation.

Q: In a microservice architecture with shared proto contracts, how do you enforce LSP across service boundaries?
A: Define a BaseMessage proto schema as the contract. All services validate incoming payloads against it before processing. Use Pact for consumer-driven contract testing: consumers publish their expectations, providers run those as tests on every deploy.

Q: What's the connection between LSP and Open-Closed Principle?
A: Open-Closed says: open for extension, closed for modification. LSP is what makes safe extension possible. If subtypes break existing contracts, the OCP guarantee collapses. LSP keeps new implementations from crashing old client code.

Examples

Basic: Rectangle and Square, the right fix

javascript
// Instead of extending Rectangle, give Square its own contract class Rectangle { constructor() { this.width = 0; this.height = 0; } setWidth(w) { this.width = w; } setHeight(h) { this.height = h; } getArea() { return this.width * this.height; } } class Square { constructor() { this.side = 0; } setSide(s) { this.side = s; } getArea() { return this.side * this.side; } } const rect = new Rectangle(); rect.setWidth(5); rect.setHeight(4); console.log(rect.getArea()); // 20 ✅ const sq = new Square(); sq.setSide(5); console.log(sq.getArea()); // 25 ✅

Removing inheritance is the correct fix. Square can't honor Rectangle's contract, so it shouldn't extend it. They share geometric similarity but not behavioral compatibility.

Intermediate: Logger interface in Express middleware

typescript
interface Logger { setLevel(level: string): void; setTimestamp(enabled: boolean): void; log(message: string): string; } class ConsoleLogger implements Logger { private level = 'info'; private timestamp = true; setLevel(l: string) { this.level = l; } setTimestamp(t: boolean) { this.timestamp = t; } log(msg: string): string { const ts = this.timestamp ? new Date().toISOString() : ''; return `${ts} [${this.level}] ${msg}`; } } class FileLogger implements Logger { private level = 'info'; private timestamp = true; setLevel(l: string) { this.level = l; } setTimestamp(t: boolean) { this.timestamp = t; } log(msg: string): string { // No hidden side effects: stable, predictable output const ts = this.timestamp ? new Date().toISOString() : ''; return `${ts} [${this.level}] ${msg}`; } } function createLogMiddleware(logger: Logger) { return (req: any, res: any, next: () => void) => { logger.setLevel('debug'); logger.setTimestamp(false); console.log(logger.log(`${req.method} ${req.path}`)); next(); }; } createLogMiddleware(new ConsoleLogger()); createLogMiddleware(new FileLogger()); // same behavior, freely substitutable

Both loggers satisfy the Logger contract without hidden mutations. Either can replace the other in any middleware chain without changing the output format or causing audit trail mismatches in production.

Advanced: Generic notifier with compile-time precondition enforcement

typescript
// Base contract: notify any payload that has an id class BaseNotifier<T extends { id: string }> { notify(payload: T): void { console.log(`Notifying ${payload.id}`); } } // WRONG: runtime precondition check on 'email' strengthens the base contract class EmailNotifierBad<T extends { id: string }> extends BaseNotifier<T> { notify(payload: T): void { if (!('email' in payload)) { throw new Error('email field is required'); // stronger precondition at runtime } console.log(`Sending to ${(payload as any).email}`); } } // RIGHT: encode the precondition in the type parameter class EmailNotifierGood<T extends { id: string; email: string }> extends BaseNotifier<T> { notify(payload: T): void { console.log(`Sending to ${payload.email}`); // email guaranteed by T's constraint } } function notifyAll<T extends { id: string }>( notifier: BaseNotifier<T>, items: T[] ): void { items.forEach(item => notifier.notify(item)); } const users = [{ id: '1', name: 'Alice' }]; // notifyAll(new EmailNotifierBad(), users); // TypeScript: ok, runtime: crash ❌ // notifyAll(new EmailNotifierGood(), users); // TypeScript: error, 'email' missing ✅

The good version moves the precondition from a runtime if check to the generic constraint. TypeScript catches the missing email before the code runs. That's LSP enforcement at the type level, which is exactly where it belongs.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?