skip to content

What is the Liskov Substitution Principle (the "L" in SOLID), and what does it require of a subtype?

level: juniorimportance: must knowfreq 85%

answer

  1. Subtype substitutable for supertype
  2. Behaviour, not just signature
  3. Preconditions ≤, postconditions ≥, invariants kept
  4. No throw-on-override, no instanceof in clients
  5. Liberal in accepting, conservative in promising

basics

~20 s

If type S is a subtype of type T, code written against T must keep working correctly when handed an S. A subtype must honour the promises its supertype made — not just match method names.

solid answer

~50 s

LSP says: objects of a subtype must be usable anywhere the supertype is expected, without the calling code needing to know the difference or breaking. Barbara Liskov's formulation is about *behavioural* subtyping: matching the compiler-checked signature is necessary but not sufficient. The subtype must also accept at least everything the supertype accepted (preconditions not strengthened), guarantee at least everything the supertype guaranteed (postconditions not weakened), and keep every invariant the supertype maintained. Practical smells that a subtype violates LSP: overriding a method to throw "not supported", requiring extra setup the base type never required, returning null or a narrower/weirder result where the base promised a value, or callers doing type checks (`if (x is Square) ...`) to work around a subtype. The fix is usually to stop inheriting: extract a common abstraction, compose, or split the interface.

go deeper

for a junior

State the substitutability definition and give one concrete broken example (an override that throws "not supported"). Say it is about behaviour, not just matching method signatures.

for a middle

Add the three Design-by-Contract rules — preconditions not strengthened, postconditions not weakened, invariants preserved — and name the smells (throw-on-override, instanceof in clients, extra required setup).

for a senior

Connect the rules to language variance (covariant returns, contravariant parameters, no broader exceptions), and to fixes: composition, interface segregation, immutability, contract tests shared across implementations.

for a principal

Frame LSP as contract governance beyond a single codebase: it is the same rule behind backward-compatible API/event-schema evolution and plugin ecosystems. Discuss deciding contract breadth up front, the history rule, and using consumer-driven contract tests as the enforcement mechanism.

## The principle The Liskov Substitution Principle (LSP) is the **L** in SOLID. It comes from Barbara Liskov and Jeannette Wing's work on *behavioural subtyping*. The usual phrasing: > 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 (correctness, task performed, etc.). Plainly: **any code written against the general type must still be correct when it is secretly handed a specialised type.** ### Terms defined - **Supertype / base type**: the general type — an interface, abstract class, protocol, or trait — that client code is written against (e.g. `Account`, `Shape`, `Collection`). - **Subtype**: a type declared to be usable in place of the supertype — via class inheritance, interface implementation, or structural conformance. - **Client code**: any code that holds a reference typed as the supertype and calls methods on it. Crucially, the client was written and tested **knowing only the supertype's documented behaviour**. - **Substitutability**: the property that swapping in a subtype instance changes nothing the client can observe as a *violation of the contract* (it may change performance or which concrete algorithm runs — that's fine). ### Why signatures are not enough Most languages check only the *syntactic* part: does the subtype have a method with a compatible name and parameter/return types? A compiler will happily accept a subtype that is behaviourally nonsense: - `Square extends Rectangle` where setting the width silently also changes the height — compiles, breaks any client that sets width and height independently. - `ImmutableList implements List` where `add()` throws `UnsupportedOperationException` — compiles, breaks any client that was told `List` supports `add`. - `FastCache extends Cache` where `get()` requires `connect()` to have been called first — compiles, breaks clients that never heard of `connect()`. LSP is about the **contract**, the behaviour promised in documentation, tests, and the type's semantics — not the method table. ### The three rules (Design by Contract framing) Bertrand Meyer's Design by Contract gives the operational checklist. For each overridden operation: 1. **Preconditions may not be strengthened.** A precondition is what the caller must guarantee before calling. If `Base.withdraw(amount)` accepts any positive amount, a subtype that additionally demands `amount <= 100` will reject calls the client was told were legal. Subtypes may *weaken* (accept more) — that never breaks an existing caller. 2. **Postconditions may not be weakened.** A postcondition is what the method guarantees on return. If `Base.find(id)` promises a non-null result or an exception, a subtype returning `null` breaks clients. Subtypes may *strengthen* (promise more) — e.g. also promise the result is sorted. 3. **Invariants must be preserved.** An invariant is a property true of the object before and after every public operation (`balance >= 0`; `width` and `height` are independent). A subtype may add invariants of its own, but may not break the base's. Liskov and Wing add a **history rule**: the subtype must not allow state changes the supertype's clients believe impossible — e.g. an "immutable point" base type subclassed by a mutable point. A memorable summary: **be liberal in what you accept, conservative in what you promise.** ### Signature variance (the type-system half) Languages encode part of the rule in their variance rules for overriding: - **Return types are covariant**: an override may return a *narrower* type (`Animal` → `Dog`). That is postcondition strengthening — safe. - **Parameter types should be contravariant**: an override could safely accept a *wider* type (`Dog` → `Animal`). That is precondition weakening — safe. Most mainstream OO languages (Java, C#, Kotlin, C++) don't actually support contravariant parameters; they require invariant parameter types, and a differing parameter type just creates an overload, not an override. - **Exceptions are covariant**: an override may throw fewer or narrower checked exceptions, never new broader ones — throwing something the client was never told about is postcondition weakening. Variance handles only the parts the compiler can see. Preconditions/postconditions/invariants expressed in *values* ("amount must be positive", "never returns empty") remain the developer's responsibility. ### How violations show up in real code - `throw new UnsupportedOperationException()` in an override. - The client doing `instanceof` / `is` / `typeof` checks and branching per subtype. - Base-class tests that fail when re-run against a subclass. - Extra required call order ("you must call `init()` first on this one"). - Configuration flags on the base whose meaning changes per subclass. ### How to fix - **Extract the true common abstraction**: if only some shapes can resize independently, `Shape` shouldn't have `setWidth`/`setHeight`; put them on `Rectangle` only. - **Prefer composition over inheritance**: `Square` *has a* side length and can expose `area()`, without pretending to be a `Rectangle`. - **Split the interface** (Interface Segregation): `ReadableList` vs `MutableList`, so immutability isn't a broken promise. - **Make objects immutable**: with no setters, the Rectangle/Square conflict evaporates because there is no mutation to be inconsistent about. - **Verify with contract tests**: one test suite written against the supertype's contract, executed against every implementation. ### Trade-offs and nuance - LSP is about *observable contract*, not perfect equivalence. Different performance, different logging, different persistence backend — all fine. - The contract is whatever clients were *told*. If the base type documents "implementations may reject amounts over their configured limit", a limiting subtype is compliant; the precondition was always that broad. Much of "LSP compliance" is therefore a documentation decision made up front. - Strict LSP is expensive to enforce in dynamic or plugin ecosystems; contract tests are the pragmatic substitute for formal proofs.

  • Does LSP apply only to class inheritance?
    No. It applies to any subtyping relation clients rely on: interface implementations, duck-typed objects in dynamic languages, protocol conformance, and even a new version of a remote API replacing the old one behind the same URL. Anywhere a caller was written against contract A and receives implementation B, LSP is the question.
  • How would you detect an LSP violation before it reaches production?
    Write contract tests: a shared test suite expressed purely in terms of the supertype's promises, parameterised over every implementation. Any subtype that can't pass it is not substitutable. Additionally, code review for `UnsupportedOperationException` in overrides and for type checks in client code.

A rental car contract says: any car you're given starts with the key, seats four, and drives on the motorway. Substituting a sportier model is fine. Substituting a go-kart that has four seats painted on but can't legally reach motorway speed breaks every plan the renter made — even though it is technically "a vehicle with four seats".

context