Liskov substitution principle
Refunds ran as a nightly loop: for every cancelled order, call refund on its payment
method. It ran clean for a year. Then cash on delivery launched. CashOnDelivery extended
PaymentMethod like the others, and its refund threw an error, because you cannot send
cash back to a source.
The first night, the loop died on the fourteenth order. The 2,300 customers queued behind it did not get their money, and none of them had paid in cash.
Every line of that code compiled. The types were all correct. This is the one SOLID principle the compiler mostly cannot check for you.
CashOnDelivery extends PaymentMethod, but cash cannot be refunded to source. What are your options for its refund() method, and which would you pick?
The idea
Barbara Liskov described it in a 1987 talk. In plain words: if your code works with a parent type, it must keep working when you hand it any subtype, without knowing or caring which one it got.
A fielder is injured and a substitute walks on. The captain places them at point and expects a catch if the ball comes. Nobody asks the substitute what they can do. If they let the ball go past because “I do not do catches”, the rules allowed the swap and the team is still broken.
The violation
class PaymentMethod {
refund(amount: number): string {
return `Refunded ${amount} to source`
}
}
class Card extends PaymentMethod {}
class CashOnDelivery extends PaymentMethod {
override refund(amount: number): string {
throw new Error('Cash cannot be refunded to source')
}
}
function refundAll(methods: PaymentMethod[], amount: number): string[] {
return methods.map((method) => method.refund(amount))
}
try {
refundAll([new Card(), new CashOnDelivery(), new Card()], 200)
} catch (error) {
if (error instanceof Error) console.log(error.message)
// > Cash cannot be refunded to source
}refundAll did nothing wrong. It was promised a list of things that can refund. One of
them lied. The second Card in the list never got its refund.
The tempting fix is if (method instanceof CashOnDelivery) continue inside refundAll.
That is the tell. The moment a caller has to check which subclass it is holding, the
subclass is not a substitute, and you are back to the if ladders from the
Polymorphism page.
What counts as a surprise
A subclass can do more than its parent. It breaks the principle when it does any of these three:
- It asks for more. The parent accepts any positive amount, the child rejects amounts over 5,000. Callers who followed the parent’s rules now fail.
- It delivers less. The parent always returns a receipt, the child sometimes returns an empty string or throws. Callers who relied on the result now fail.
- It breaks a rule the parent kept. The parent’s balance never goes negative, the child lets it. Everything built on that rule is now wrong.
A quick test that catches all three: take the tests you wrote for the parent and run them
against the child. If any fail, it is not a subtype, whatever extends says.
The fix: stop claiming what you cannot do
The mistake was upstream. “Every payment method can refund to source” was never true. So
do not put refund on the type that every method shares.
interface PaymentMethod {
pay(amount: number): string
}
interface Refundable {
refund(amount: number): string
}
class Card implements PaymentMethod, Refundable {
pay(amount: number): string {
return `Card charged ${amount}`
}
refund(amount: number): string {
return `Refunded ${amount} to card`
}
}
class CashOnDelivery implements PaymentMethod {
pay(amount: number): string {
return `Collect ${amount} at the door`
}
}
function refundAll(methods: Refundable[], amount: number): string[] {
return methods.map((method) => method.refund(amount))
}
console.log(refundAll([new Card(), new Card()], 200))
// > [ 'Refunded 200 to card', 'Refunded 200 to card' ]
refundAll([new Card(), new CashOnDelivery()], 200)
// Error: Property 'refund' is missing in type 'CashOnDelivery' but
// required in type 'Refundable'.refundAll now asks for Refundable[], and cash on delivery cannot get into that list.
The crash at 2am has become a red line in the editor.
Cash orders still need their money back, as wallet credit perhaps. That is a different
operation with a different name, handled by different code. Forcing it under refund
was the original mistake.
implements PaymentMethod, Refundable is new syntax: a class can implement as many
interfaces as it likes, separated by commas. Splitting an interface by capability like
this is the subject of the next page.
What the compiler does check
TypeScript will stop you from changing the shape of a method in a subclass.
class Parent {
price(): number {
return 100
}
}
class Child extends Parent {
override price(): string { return 'free' }
// Error: Type '() => string' is not assignable to type '() => number'.
}So the types are covered. What nobody checks for you is behaviour: whether it throws, whether it returns something sensible, whether it keeps the parent’s rules. That part is your job, and tests are how you do it.
The one every interviewer asks
A square is a rectangle. Every school textbook says so. In code:
class Rectangle {
constructor(protected width: number, protected height: number) {}
setWidth(width: number): void {
this.width = width
}
setHeight(height: number): void {
this.height = height
}
area(): number {
return this.width * this.height
}
}
class Square extends Rectangle {
override setWidth(width: number): void {
this.width = width
this.height = width
}
override setHeight(height: number): void {
this.width = height
this.height = height
}
}
function stretch(shape: Rectangle): number {
shape.setWidth(5)
shape.setHeight(4)
return shape.area()
}
console.log(stretch(new Rectangle(1, 1))) // 20
console.log(stretch(new Square(1, 1))) // 16stretch was written for a rectangle and expects 20. Handed a square, it gets 16.
Setting the height quietly changed the width, which no rectangle does.
The point they want: “is a” in everyday language is about what a thing is. In code it
is about how a thing behaves. A square is a rectangle in geometry and is not one
in a program where rectangles have independent setters. Then give a fix. Make both
immutable, so there are no setters to disagree about, or give them a shared Shape
interface with only area() and no parent and child relationship at all.
Throwing is the loud version. The quiet version is an override that does nothing:
override save(): void {} on a read only subclass. The caller believes the data was
saved. This one will not crash your nightly job. It loses data without telling anyone,
and you find out weeks later.
Substitute or not?
class Card implements PaymentMethod, Refundablemethods: Refundable[]override price(): stringconstructor(protected width: number, protected height: number)class Card extends PaymentMethod {}throw new Error("...")Checkpoint
1. CashOnDelivery.refund() throws while its parent refunds normally. TypeScript compiles it without complaint. Why?
2. What is the right fix for a subclass that cannot honour one of the parent methods?
3. Square extends Rectangle and keeps its sides equal in both setters. A function sets width 5 and height 4 and expects area 20. What does it get for a square, and what is the lesson?
Liskov substitution says that anywhere my code uses a parent type, I must be able to hand it any subtype and have it still work, without checking which one it is. A subclass breaks that if it asks for more than the parent did, delivers less, or breaks a rule the parent kept. The usual sign is an override that throws not supported or does nothing, or a caller that needs an instanceof check. The compiler only verifies signatures, so this is about behaviour and I catch it by running the parent's tests against the child. The fix is not a better error message. It is changing the design so the type only promises what every implementation can do, usually by moving the method to a smaller interface.
