LearnLLDSOLIDL: Liskov substitution

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.

Your answer

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.

The picture to keep
The substitute fielder

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.

In one line: A subclass must work anywhere its parent works, with no surprises.

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))) // 16

stretch 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.

How to answer the square question

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.

The empty override

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?

Would code written for the parent still work?
SavingsAccount extends Account and adds addInterest().
ReadOnlyFile extends File and write() throws.
PremiumUser extends User and getDiscount() returns 20 instead of 0.
Penguin extends Bird and fly() throws.
CachedRepository extends Repository and returns results up to an hour old.
TypeScript you just picked up
class Card implements PaymentMethod, Refundable
A class can implement several interfaces. It must provide every method of each.
methods: Refundable[]
Ask for exactly the capability you use. Objects without it are rejected at compile time.
override price(): string
Rejected when the parent returns number. An override must keep a compatible signature.
constructor(protected width: number, protected height: number)
Parameter properties with protected, so the subclass can set them.
class Card extends PaymentMethod {}
An empty body is legal. The class inherits everything unchanged.
throw new Error("...")
A function that always throws still type checks against any return type. That is why the compiler cannot catch this violation.

Checkpoint

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?

Say this in 60 seconds

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.

IndGeek provides solutions in the software field, and is a hub for ultimate Tech Knowledge.