Module 02 · SOLID and friends · Lesson 3 of 7
Liskov substitution
A subtype must work anywhere its parent is expected without surprising callers. The rules for preconditions, postconditions, invariants and exceptions, classic violations like unsupported operations, and how to repair a hierarchy.
Why it matters
Polymorphism promises that code written against a type works with every subtype. That promise is only as good as the subtypes. One subclass that throws on a method the parent supports, or quietly skips a side effect, turns every caller into a minefield: each must now check which concrete type it really has, which defeats the point of the abstraction. The Liskov substitution principle states precisely what a subtype must honour so the promise holds.
The idea
If S is a subtype of T, then objects of type T can be replaced with objects of type S without changing the correctness of the program. Compiling is not enough; the subtype must behave as callers of T are entitled to expect. In terms of the contract from the interfaces lesson:
| Rule | A subtype may | A subtype must not |
|---|---|---|
| Preconditions | Accept more inputs than the parent | Reject inputs the parent accepts |
| Postconditions | Promise more than the parent | Deliver less, or skip a promised side effect |
| Invariants | Add its own | Break the parent's |
| Exceptions | Throw subtypes of the documented exceptions | Throw new kinds of failure callers were never told about |
| History | Add new state | Allow changes the parent forbids (for example mutating an object the parent guarantees immutable) |
Classic violations
- 1
Unsupported operations
A ReadOnlyList that extends List and throws on add. Every caller that adds to a List now fails at runtime for this one subtype. The type claimed a capability it does not have. - 2
Strengthened preconditions
A Shipping interface accepts any parcel; an ExpressShipping implementation throws for parcels over 5 kg. Callers written against Shipping have no way to know. - 3
Weakened postconditions
A Repository promises that save makes the entity findable; a caching implementation saves asynchronously and find returns nothing for a few seconds. - 4
Broken invariants
A Rectangle subtype that forces width and height to stay equal breaks callers who set width and height independently and expect the area to follow. Mathematical 'is a' is not behavioural 'is a'. - 5
Type checks in callers
Code full of 'if (x instanceof Special)' is usually compensating for a subtype that does not substitute cleanly.
Repairing a broken hierarchy
The fix is almost never "add a type check in the caller". The options are:
- Narrow the abstraction: split a fat type into capabilities. A
ReadableListinterface thatListextends lets read-only collections implement only what they support; callers that need to add ask for the writable type. - Make the limitation part of the contract: if not every shipping method takes every parcel, the interface should say so, for example
supports(parcel)or a quote step that can decline, so callers handle it honestly. - Use composition instead of inheritance: a Square that holds side length and is not a Rectangle at all, both implementing a
Shapewitharea(). - Make objects immutable: many substitution problems involve mutation; immutable rectangles and squares can share a read-only supertype without conflict.
How to check
Write contract tests against the parent type and run them for every subtype. If a subtype needs its own exceptions to those tests, the hierarchy is wrong. In design reviews, ask of each subclass: "Could every caller of the parent use this without knowing?"
Trade-offs
Respecting substitution often means smaller interfaces, more honest contracts and fewer inheritance relationships, which can feel like more types to manage. In return, polymorphic code stays correct as new subtypes appear, and callers stay free of type checks. Ignoring it buys a quick reuse of a parent's code at the cost of bugs that appear far from the subtype that caused them.
In an interview
Use it to justify hierarchy choices: "Not every payment method supports refunds, so rather than having some implementations throw, refunds are a separate Refundable capability that only card and wallet implement, and the refund flow asks for a Refundable." It shows you design hierarchies around behaviour, not around nouns.
Check yourself
A Bird base class has a fly() method. Penguin extends Bird and throws UnsupportedOperationException from fly(). A migration routine that calls fly() on every Bird crashes. What is the principled fix?