open atlas
↑ Back to track
Code patterns & craft CP · 05 · 03

Liskov substitution

A subtype must be substitutable for its supertype without surprising callers. LSP is about behavioral contracts, not type compatibility — the compiler accepts the subclass; the runtime betrays it. The tell is `instanceof` special-casing at call sites.

CP Senior ◷ 19 min
Level
FoundationsJuniorMiddleSenior

Square extends Rectangle. It compiles. Every test on Rectangle passes when you pass a Rectangle. Then a function that resizes a rectangle — setWidth(5); setHeight(4); assert(area === 20) — gets handed a Square, and the assertion blows up: a square forced its height to follow its width, so the area is 16. Nothing was mistyped. The type system was satisfied. The program is still wrong.

That gap — between the compiler accepts it and the caller can actually rely on it — is what the Liskov Substitution Principle is about. LSP is not a rule about types matching. It is a rule about behaviour matching, and TypeScript’s structural checker cannot enforce it for you.

Goal

After this lesson you can state LSP as a behavioral-subtyping contract (no stronger preconditions, no weaker postconditions, no new exceptions, preserved invariants); recognize the two classic violations (Rectangle/Square and a read-only collection that throws on add); read instanceof Sub special-casing at a call site as the tell that a subtype broke substitutability; and choose composition over inheritance when a subclass cannot honor the parent’s contract.

1

LSP: a subtype must be substitutable for its supertype without surprising any code that holds the supertype. Barbara Liskov’s formulation: if S is a subtype of T, then objects of type T may be replaced with objects of type S without altering any of the desirable properties of the program. The practical reading: every function written against T — using only what T’s contract promises — must keep working when handed an S, with no special knowledge that an S is present.

This is a constraint on the subclass author, paid to a caller they will never meet. The caller coded against Rectangle. The subclass author must make Square behave like a Rectangle in every way that caller could legitimately depend on. If they can’t, Square is not a subtype of Rectangle — whatever the extends keyword says.

2

The contract has four clauses; violate any one and substitution breaks. Behavioral subtyping spells out exactly what “behave like the supertype” means:

abstract class Account {
  // precondition: amount > 0
  // postcondition: balance decreases by exactly `amount`; never throws for a valid amount
  abstract withdraw(amount: number): void;
}
  • Don’t strengthen preconditions. The subtype may not demand more of callers than the parent did. If Account.withdraw accepts any positive amount, a subtype that rejects amounts over 100 breaks callers who reasonably passed 500.
  • Don’t weaken postconditions. The subtype must deliver at least what the parent promised. If withdraw guarantees the balance drops by amount, a subtype that sometimes drops it by less has lied to every caller.
  • Don’t throw new exceptions the parent’s contract never declared. A caller that handled withdraw’s documented errors will not catch a surprise NotSupportedError.
  • Preserve invariants the parent maintains (e.g. “balance is never negative”). The subtype inherits the duty to keep them true.

These are the only knobs. Every LSP violation is one of these four, made concrete.

3

Rectangle/Square: the subtype strengthens an invariant the parent didn’t have, and it breaks callers. The textbook case. A Rectangle lets width and height vary independently — that independence is part of its contract, even though no method declares it.

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

class Square extends Rectangle {
  // a square must keep w === h, so each setter mutates both
  setWidth(w: number) { this.w = w; this.h = w; }
  setHeight(h: number) { this.w = h; this.h = h; }
}

// a caller written against Rectangle's contract
function grow(r: Rectangle) {
  r.setWidth(5);
  r.setHeight(4);
  return r.area(); // a Rectangle reader expects 20
}

grow(new Rectangle(1, 1)); // 20  ✅
grow(new Square(1, 1));    // 16  ❌  — setHeight clobbered the width

grow did nothing wrong; it used only Rectangle’s public contract. Square strengthened it with a new invariant (w === h) that the parent never promised to keep, and that extra invariant weakened the postcondition of setWidth (it no longer leaves height alone). Geometrically a square is-a rectangle; behaviorally, a mutable Square is not a substitutable Rectangle. The is-a intuition is the trap.

4

The read-only collection: the subtype throws a new exception, so callers can’t treat it as the supertype. A second canonical shape, and the one you hit more often in real code.

class List<T> {
  protected items: T[] = [];
  add(item: T) { this.items.push(item); }
  get(i: number) { return this.items[i]; }
}

class ReadOnlyList<T> extends List<T> {
  add(_item: T): never {
    throw new Error("ReadOnlyList is immutable"); // a NEW exception the parent never threw
  }
}

function appendAuditEntry(log: List<string>) {
  log.add("user logged in"); // valid against List's contract — but throws for ReadOnlyList
}

ReadOnlyList is type-compatible — it has every method List has. The compiler is happy. But it violates the “no new exceptions” clause: appendAuditEntry, holding a List, calls the contractually-valid add and gets an exception the List contract never warned about. ReadOnlyList is a narrower behaviour wearing a wider type. The fix is to invert the hierarchy: the read-only interface is the supertype, and the mutable list extends it by adding add — you never inherit a capability and then take it away.

5

The tell: when callers start writing if (x instanceof Sub), a subtype has already broken LSP. A subtype that doesn’t truly substitute forces the caller to discover its real type and branch on it:

function grow(r: Rectangle) {
  if (r instanceof Square) {
    // special-case the thing that was supposed to be substitutable
    r.setWidth(5);
    return r.area();
  }
  r.setWidth(5);
  r.setHeight(4);
  return r.area();
}

The moment that instanceof appears, the abstraction has failed: the caller now knows about the subclass, which is exactly the coupling polymorphism was meant to remove. Every new subtype adds another branch here (an Open/Closed violation too — the function is no longer closed to new types). So instanceof Sub inside code that holds the supertype is not just ugly; it is the compiler-invisible LSP violation made visible. Treat it as a failed-substitution alarm, not a coding-style nit.

Worked example

A real violation, and the composition fix. A payment system models a “free” payment method as a subclass of CreditCardPayment because it wanted to reuse the receipt and logging code:

// BEFORE — inheritance chosen for reuse, not substitutability
class CreditCardPayment {
  charge(amountCents: number): ChargeResult {
    if (amountCents <= 0) throw new Error("amount must be positive"); // parent's precondition
    return this.gateway.charge(amountCents);
  }
}

class FreeTrialPayment extends CreditCardPayment {
  charge(amountCents: number): ChargeResult {
    if (amountCents !== 0) throw new Error("free trial must be 0"); // STRENGTHENED precondition
    return { ok: true, id: "free" };
  }
}

function checkout(p: CreditCardPayment, cents: number) {
  return p.charge(cents); // valid for any cents > 0 against the parent's contract
}

checkout(new FreeTrialPayment(), 999); // throws — the subtype demands MORE of the caller

FreeTrialPayment strengthened the precondition (charge now requires zero), so a caller holding a CreditCardPayment and passing 999 — perfectly legal per the parent — gets an exception. To keep it working, checkout would have to special-case with instanceof FreeTrialPayment. That instanceof is the alarm.

The fix is to stop inheriting a contract you can’t keep and compose behind a shared interface instead:

// AFTER — a common contract, no fake is-a, no strengthened precondition
interface PaymentMethod {
  charge(amountCents: number): ChargeResult; // contract: handles its own valid range, returns a result
}

class CreditCardPayment implements PaymentMethod {
  constructor(private receipts: ReceiptService) {}
  charge(cents: number) { return this.gateway.charge(cents); }
}

class FreeTrialPayment implements PaymentMethod {
  constructor(private receipts: ReceiptService) {} // reuse by composing the service, not by extending
  charge(_cents: number) { return { ok: true, id: "free" }; }
}

function checkout(p: PaymentMethod, cents: number) {
  return p.charge(cents); // every PaymentMethod honors the same contract — substitutable
}

Now there is no parent contract for a subtype to violate: each method satisfies PaymentMethod on its own terms, the receipt code is shared by composition (ReceiptService), and checkout never needs to know which method it holds. The lesson: inheritance was chosen for code reuse, but the price was a broken contract. When in doubt, reuse by composition — it carries no substitutability obligation.

Why this works

Why doesn’t TypeScript catch this? Because TS subtyping is structural: Square is assignable to Rectangle precisely because it has all of Rectangle’s members with compatible signatures. The type system reasons about shapes — method names and parameter/return types — and a behavioral contract like “width and height vary independently” or “add succeeds” is not part of any shape. No mainstream language encodes full behavioral contracts in its types; that would require dependent types or runtime contract checking. So LSP lives in the space the compiler can’t see: it is a design discipline, verified by tests and review, not by tsc. “It compiles” is necessary for substitutability and nowhere near sufficient.

Common mistake

The seductive mistake is reaching for extends to get code reuse — “I’ll inherit so I don’t rewrite the logging/receipt/validation code.” Inheritance gives you reuse and an is-a/substitutability obligation, bundled together; you wanted only the first. The moment the subclass needs to refuse, restrict, or re-throw something the parent allowed, that bundled obligation breaks and the violation leaks into callers. The rule of thumb: inherit only when the subclass is a true behavioral substitute for the parent in every context the parent is used; otherwise compose. Most “reuse” inheritance in real codebases should have been a shared collaborator or a common interface.

Check yourself
Quiz

A teammate adds `class ReadOnlyList<T> extends List<T>` whose `add()` throws 'immutable'. It compiles, and they say 'the type checker is happy, so it's a valid subtype.' What's the senior objection?

Recap

The Liskov Substitution Principle says a subtype must be substitutable for its supertype without surprising callers — honor the behavioral contract: don’t strengthen preconditions, don’t weaken postconditions, don’t throw new exceptions, preserve invariants. It is about behavioral subtyping, not mere type compatibility: the compiler checks shapes and will happily accept Square extends Rectangle or a ReadOnlyList whose add() throws, while the runtime betrays the contract a caller relied on. The reliable tell is if (x instanceof Sub) appearing in code that holds the supertype — the abstraction has failed and substitution is broken (and Open/Closed with it). The root cause is almost always inheritance chosen for code reuse rather than true substitutability; when in doubt, reuse by composition, which carries no is-a obligation to break.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 4 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.