Liskov Substitution Principle (LSP)
Status: Complete
Category: SOLID Principles
Default enforcement: Advisory
Author: PushBackLog team
Tags
- Topic: quality, architecture
- Methodology: SOLID
- Skillset: any
- Technology: generic
- Stage: execution, review
Summary
Objects of a subtype should be substitutable for objects of their base type without altering the correctness of the program. If code is written to work with a Bird, it should work correctly when given a Duck, a Sparrow, or any other Bird — without the caller needing to know or care which specific type it has received.
Rationale
What “correctly” means
Barbara Liskov’s formal definition (1987) is precise: 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. In practice, “desirable properties” means: no exceptions thrown that the base type wouldn’t throw, no weaker guarantees on postconditions, no stronger restrictions on preconditions.
The classic violation is the Square/Rectangle problem: if Square extends Rectangle, then setting width on a Square must also set height (otherwise it’s no longer a square). But code written to work with Rectangle assumes width and height are independent — and breaks when it receives a Square. The inheritance relationship violated LSP.
The trust relationship
LSP is ultimately about trust. Callers trust that a type’s contract — its behaviour, the exceptions it does and doesn’t throw, the invariants it upholds — is honoured by every subtype. When a subtype violates that contract, callers must start checking types at runtime (instanceof) to defend themselves, which defeats the purpose of abstraction entirely.
LSP and OCP
LSP is a precondition for OCP working as intended. The OCP relies on being able to extend a system with new implementations without modifying existing code. That only holds if those new implementations truly honour the contract of the abstraction they implement. A ReportFormat implementation that throws UnsupportedOperationException from render() in certain conditions breaks every caller that trusted the ReportFormat contract.
Guidance
Checklist for LSP
When creating a subtype or implementing an interface, verify:
| Check | What to test |
|---|---|
| Preconditions | Does this implementation accept all inputs the base type accepts? (Do not strengthen preconditions) |
| Postconditions | Does this implementation return what the base type promises? (Do not weaken postconditions) |
| Invariants | Does this implementation preserve all invariants the base type guarantees? |
| Exceptions | Does this implementation only throw exceptions the base type declares? |
| Behaviour | Does a caller using only the base type’s interface get sensible, expected behaviour? |
”Is-a” vs “behaves-as”
Inheritance in OOP is often taught as modelling “is-a” relationships. LSP refines this: it should model “behaves-as” relationships. A Square is a rectangle geometrically, but in code it does not behave as one under mutation. Inheritance must be grounded in behavioural compatibility, not taxonomic similarity.
When a subtype requires the caller to know its specific type to use it safely, the hierarchy is wrong. Consider composition over inheritance in these cases.
Examples
LSP violation — the classic Rectangle/Square
class Rectangle {
constructor(protected width: number, protected height: number) {}
setWidth(w: number) { this.width = w; }
setHeight(h: number) { this.height = h; }
area() { return this.width * this.height; }
}
class Square extends Rectangle {
setWidth(w: number) { this.width = w; this.height = w; } // Violates LSP
setHeight(h: number) { this.width = h; this.height = h; } // Violates LSP
}
// Caller written against Rectangle contract — breaks with Square
function resize(rect: Rectangle) {
rect.setWidth(5);
rect.setHeight(3);
assert(rect.area() === 15); // Fails when rect is a Square: area is 9
}
LSP-compliant design
// Don't inherit — model the correct abstraction
interface Shape {
area(): number;
}
class Rectangle implements Shape { ... }
class Square implements Shape { ... }
Both satisfy the Shape contract. No caller is surprised.
Notification service example
interface Notifier {
send(message: string, recipient: string): Promise<void>;
}
// Good: EmailNotifier, SmsNotifier, SlackNotifier all implement Notifier
// — any caller using Notifier works the same with any implementation
// Bad: a notifier that throws for recipient formats it doesn't like,
// that weren't excluded in the interface contract
class FaxNotifier implements Notifier {
send(message: string, recipient: string): Promise<void> {
if (!recipient.startsWith('+')) throw new Error('Fax needs E.164 number');
// Caller didn't know about this precondition — LSP violated
}
}
Anti-patterns
1. Throwing NotImplementedException from interface methods
An implementation that throws NotImplementedException (or equivalent) for methods it doesn’t support is the most egregious LSP violation. Callers can never trust that any method on the interface will work.
2. Runtime type-checking to work around subtype failures
if (notifier instanceof SmsNotifier) {
// special handling
}
Any time a caller needs to check what concrete type it has in order to use it correctly, the abstraction has failed LSP.
3. Overriding methods to do nothing or call super then undo it
A subtype that silently ignores a method call (empty override) weakens the postcondition of that method. A subtype that calls super.doThing() and then partially reverses what it did is unpredictable.
4. Inheriting for code reuse without behavioural compatibility
Inheriting from a class purely to reuse its private methods, while the subtype doesn’t genuinely behave as the base type in all contexts, is implementation inheritance. Use composition instead.
Related practices
Part of the PushBackLog Best Practices Library. Suggest improvements →