Skip to content
Preptima
mediumConceptMidSeniorStaff

Give me a subclass that the compiler accepts but that still breaks the Liskov substitution principle. What rule does it break?

Substitutability is a claim about behaviour, and signatures carry none of it. A subclass that rejects an argument its supertype accepted has strengthened a precondition, which the compiler cannot see: the code type-checks, and it breaks callers that were written correctly against the base type.

4 min readUpdated 2026-07-29Target archetype: Big Tech, Enterprise Captive, Product Startup
Practice answering out loud

What the interviewer is scoring

  • Whether the direction of the rules is stated correctly, that preconditions may only weaken and postconditions only strengthen
  • Does the example break behaviour rather than merely change a signature or a return type
  • That they can say why the compiler cannot detect it, given what a type declaration actually asserts
  • Whether they can point to a real library where the supertype was widened to legalise the behaviour
  • Can they describe a test arrangement that would have caught the violation before release

Answer

The compiler checks shape, not promises

When you declare that a class extends another, the compiler verifies a small set of structural facts: the overriding method exists, its parameter types match, its return type is the same or narrower, its access is not more restrictive, and it declares no new checked exceptions. That is the entire assurance. Nothing in the type system records what the supertype's method promised to do, which values it accepted, or what had to be true afterwards, so nothing can check that the subtype still honours any of it.

Liskov substitution is the missing half. It says code written against the supertype must keep working when handed any subtype, without knowing which one it has, and it decomposes into four rules that can be checked by reading:

  • Preconditions may be weakened, never strengthened. A subtype must accept every input the supertype accepted, and may accept more.
  • Postconditions may be strengthened, never weakened. It must guarantee everything the supertype guaranteed, and may guarantee more.
  • Invariants of the supertype must be preserved, including ones the supertype never wrote down.
  • No new exception types, and nothing thrown where the supertype promised a result.

The direction of the first two is the part candidates reverse under pressure, and getting it right is most of the signal in this question.

A subclass that type-checks and lies

class Account {
    /** Withdraws any amount up to the balance. */
    void withdraw(BigDecimal amount) { ... }
}

class RestrictedAccount extends Account {
    private static final BigDecimal CAP = new BigDecimal("500");

    @Override
    void withdraw(BigDecimal amount) {
        // Rejects inputs the supertype accepted: the precondition has been
        // strengthened, and no compiler will tell you.
        if (amount.compareTo(CAP) > 0) throw new IllegalArgumentException("over cap");
        super.withdraw(amount);
    }
}

This compiles cleanly. IllegalArgumentException is unchecked, so the exception rule is not enforced either. And it is entirely reasonable-looking code that somebody will write in a sprint, because the domain genuinely has restricted accounts.

The damage lands on a caller that never mentions RestrictedAccount. A batch payment routine holding an Account reference, correct against the documented contract, now fails on some accounts and not others, for a reason that appears nowhere in its own source. The subclass has narrowed the set of inputs the type accepts, so the base type no longer describes what you can do with a value of that type. That is the practical meaning of a substitution violation: the abstraction has stopped being usable without knowing the concrete class, and every caller must now do exactly what polymorphism was supposed to remove.

The other classic breaks the invariant rule rather than the precondition rule. A Square extending a mutable Rectangle must keep width and height equal, so setting the width has to change the height, and any caller that sets both dimensions and then asserts the area is now wrong. The invariant the subclass added is incompatible with a postcondition the supertype implied but never stated.

What the JDK did instead

The interesting comparison is a library that faced the same problem and chose differently. Collections.unmodifiableList and List.of return objects that throw UnsupportedOperationException from add, which looks exactly like the violation above. The difference is that List documents mutation as an optional operation, so the throw is written into the supertype's contract rather than smuggled in by the subtype. Every caller of List.add is on notice that it may throw, and no substitution has been broken because the base promise was never as strong as it appeared.

That is the legitimate escape route, and it has a real price: the supertype's contract is now weaker for everybody, so all callers must handle a case that most implementations never produce. Widening a base contract to accommodate one subtype is a decision to make every consumer's code more defensive, and it is only worth it when the weaker contract genuinely describes the abstraction. Where it does not, the right answer is that the inheritance relationship is wrong — RestrictedAccount is not an Account in the sense callers rely on, and the cap belongs in a policy the caller consults before deciding, or in a separate type that does not claim substitutability.

Java's own arrays show that even the language ships a hole here. Arrays are covariant, so a String[] can be assigned to an Object[], and storing an Integer through that reference compiles and then throws ArrayStoreException at runtime. Generics were deliberately made invariant to avoid repeating it, which is why List<String> is not a List<Object>, and why wildcards exist to recover the flexibility safely.

Catching it before a caller does

Because the violation is behavioural, only behavioural checks find it. The arrangement that works is a test suite written entirely against the supertype, parameterised over every implementation, asserting the contract and nothing else: that any input the base accepts is accepted, that the documented postconditions hold, that the invariants survive a sequence of operations rather than a single call. When someone adds an implementation that narrows the contract, an existing test fails against the new class, and the failure names the rule rather than the symptom.

Two habits reduce how often you need it. Prefer composition when the relationship is "uses" or "is constrained like", not "is a": the restricted account holds an account and applies a policy, and no caller is misled. And when you do use inheritance, write the contract down in the supertype — what it accepts, what it guarantees, what may throw — because a rule that was never stated cannot be broken deliberately and will therefore be broken accidentally.

Substitutability is a promise about behaviour, and the compiler checks none of it: an override may only widen what it accepts and only strengthen what it guarantees. When a subtype needs to accept less, either the base contract was wrong, or the inheritance was.

Likely follow-ups

  • How do you choose between widening the supertype's contract and abandoning the inheritance relationship?
  • Why does adding a field and overriding equals break symmetry, and what are the two ways out of it?
  • Java's arrays are covariant. Where does that surface at runtime, and what did generics do differently?
  • What would a contract test suite for an interface with optional operations have to assert?

Related questions

liskov-substitutioninheritancecontractspolymorphismapi-design