Cruxly

Cruxly › Low-level design › Principles & Patterns

Liskov Substitution

a subtype must not surprise the caller

Open the interactive lesson

The principle: if S is a subtype of T, objects of type T should be replaceable with objects of type S without altering the correctness of the program.

Not "compiles" — correctness. LSP is about behaviour, and it is the SOLID principle most often broken by code that type-checks perfectly.

Where it comes from. This one is Barbara Liskov's, from her 1987 OOPSLA keynote "Data Abstraction and Hierarchy". She and Jeannette Wing stated it formally in 1994 (A Behavioral Notion of Subtyping): if some property can be proven about objects of type T, it must also hold for objects of any subtype S. Martin's contribution was putting it into the SOLID set as a design rule for everyday inheritance.

The key word in the 1994 version is property. A subtype that keeps every method signature but changes what a caller can rely on — a result that used to be non-null, an operation that used to succeed, a value that used to stay independent of another — breaks LSP even though nothing fails to compile.

The canonical violation — Square extends Rectangle

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

class Square extends Rectangle {
    @Override void setWidth(int w)  { width = height = w; }   // must keep width==height
    @Override void setHeight(int h) { width = height = h; }
}

This compiles. It is a real is-a relationship geometrically. And it breaks any code written against Rectangle's contract:

void resize(Rectangle r) {
    r.setWidth(5);
    r.setHeight(10);
    assert r.area() == 50;    // FAILS for a Square: area() is 100
}

resize is correct for every Rectangle and wrong for the Square subtype — which is precisely what LSP forbids. The bug is not in resize; it is in modelling Square as a Rectangle at all, because Rectangle's implicit contract ("width and height vary independently") is not one Square can honour.

The fix — model the actual relationship

interface Shape { int area(); }

class Rectangle implements Shape {
    private final int width, height;
    Rectangle(int w, int h) { width = w; height = h; }
    public int area() { return width * height; }
}

class Square implements Shape {
    private final int side;
    Square(int side) { this.side = side; }
    public int area() { return side * side; }
}

Neither is a subtype of the other. Both satisfy Shape's actual contract — "has an area" — and neither promises independent width/height mutation that the other cannot honour. Immutability sidesteps the entire problem class: if a shape cannot be resized after construction, there is no setWidth to violate.

The version of this bug that actually appears in interviews

It rarely shows up as literal squares and rectangles. It shows up as:

class ReadOnlyList<T> extends ArrayList<T> {
    @Override public boolean add(T item) {
        throw new UnsupportedOperationException();   // "read-only," so... throw?
    }
}

Any code that holds an ArrayList<T> and calls .add() — which ArrayList fully supports — now crashes at runtime for this specific subtype, with no compiler warning anywhere. (The List interface itself marks add as an optional operation that may throw, as List.of lists do; the broken promise here is ArrayList's, which never throws.) This is the pattern to watch for: a subtype that throws, silently no-ops, or narrows the precondition of a method its parent promises to support. The fix is usually the same as the Square/Rectangle fix — model "read-only" and "mutable" as separate interfaces (ReadableList / MutableList) rather than one extending the other.

Where the contract actually breaks

The compiler sees a valid override. The contract sees a promise being withdrawn. Those are different things, and the picture makes the gap visible.

resize(Rectangle r) setWidth(5) setHeight(10) expects area == 50 Rectangle w=5 h=10 independent area() = 50 Square setHeight also set w=10 area() = 100 caller correct contract honoured caller broken compiles perfectly The bug is not in resize(). It is in the claim that a Square is a Rectangle.
Substitutability is a property of behaviour, not of the type hierarchy. Both branches type-check; only one keeps the promise the caller was written against.

The four rules a subtype must not break

"Don't surprise the caller" is the intuition. These are the four specific ways subtypes break it — worth knowing by name, because naming the exact rule being violated is much stronger than "that feels wrong."

@@ 1 | Preconditions may be weakened, never strengthened

If the parent accepts any non-null string, the child must not demand a non-empty one. A caller holding the parent type has no way to know the extra restriction exists.

class Parent { void send(String msg) { ... } }            // any non-null
class Child extends Parent {
    @Override void send(String msg) {
        if (msg.isEmpty()) throw new IllegalArgumentException();  // stricter!
        super.send(msg);
    }
}

@@ 2 | Postconditions may be strengthened, never weakened

If the parent guarantees the returned list is sorted, the child must also return it sorted. Promising more is fine; promising less is a broken contract.

@@ 3 | Invariants of the parent must be preserved

Rectangle's unstated invariant is that width and height move independently. Square preserves a different invariant (width equals height) at the cost of the parent's — which is exactly the violation above.

@@ 4 | No new exceptions the parent did not declare

ReadOnlyList.add() throwing UnsupportedOperationException is this rule breaking. Callers wrote list.add(x) against ArrayList, whose contract says that call succeeds.

:::key The one-sentence test Ask: could I replace every instance of the parent with this child, in code I have never read, without anything breaking? If answering requires knowing which concrete type is in play, the substitution is not safe — and needing to know the concrete type defeats the entire point of programming to the parent. :::

How to fix it, and the case for composition

When a subtype cannot honour the parent's contract, there are three honest moves and one dishonest one.

1. Make both siblings under a narrower interface. The Shape fix above. Correct when neither type is really a specialisation of the other — they just share a capability.

2. Remove the mutation that caused the conflict. Square is only a problem because Rectangle is mutable. Immutable value types cannot violate LSP through setters, because there are none. This is the reason so much modern design defaults to immutability, and it is worth saying as the general fix rather than a trick specific to shapes.

3. Use composition instead of inheritance. If Square genuinely needs rectangle behaviour internally, let it hold one rather than be one:

class Square {
    private final Rectangle inner;          // has-a, not is-a
    Square(int side) { inner = new Rectangle(side, side); }
    int area() { return inner.area(); }     // expose only what makes sense
}

Now Square cannot be passed where a Rectangle is expected, which is exactly right — that substitution was never safe. Composition lets you reuse the implementation without inheriting a contract you cannot keep.

The dishonest move is a type check inside the parent's method (if (this instanceof Square) ...). That is the violation, rewritten so it is harder to find.

:::warn Why this shows up so often in real codebases Inheritance is the first reuse mechanism most people learn, so it gets reached for whenever two things look similar. But "similar" is not the test — "substitutable in every context the parent is valid in" is. The gap between those two is where most LSP violations live, and it widens as the parent gains methods over time: a subtype that was substitutable in 2019 can quietly stop being so when someone adds a method to the base class. :::

How to say it in the interview

"The classic tell is a subtype that throws or does nothing on a method its parent promises to actually perform — the type system says the substitution is fine, and the behaviour says it very much is not."

Checklist

Practice this interactively
Share this lessonWhatsAppLinkedInX

Related lessons