Liskov Substitution: fix a subtype that breaks callers
Make every subtype work wherever its base type already works.
Watch the narrated video
Every step
Problem
Steps 1–5Refactor
Steps 6–9Payoff
Steps 10–11Recap
Steps 12–13The code: before and after
Before
abstract class PaymentMethod {
abstract charge(amount: number): string;
abstract refund(amount: number): string;
}
class Card extends PaymentMethod {
charge(amount: number): string {
return `card charged ${amount}`;
}
refund(amount: number): string {
return `card refunded ${amount}`;
}
}
class Checkout {
pay(method: PaymentMethod, amount: number): string {
return method.charge(amount);
}
}
class Returns {
giveBack(method: PaymentMethod, amount: number): string {
return method.refund(amount);
}
}
function shop(): string[] {
const card = new Card();
return [
new Checkout().pay(card, 40), // card charged 40
new Returns().giveBack(card, 40), // card refunded 40
];
}After
interface Refundable {
refund(amount: number): string;
}
abstract class PaymentMethod {
abstract charge(amount: number): string;
}
class Card extends PaymentMethod implements Refundable {
charge(amount: number): string {
return `card charged ${amount}`;
}
refund(amount: number): string {
return `card refunded ${amount}`;
}
}
class GiftCard extends PaymentMethod {
charge(amount: number): string {
return `gift card charged ${amount}`;
}
}
class Checkout {
pay(method: PaymentMethod, amount: number): string {
return method.charge(amount);
}
}
class Returns {
giveBack(method: Refundable, amount: number): string {
return method.refund(amount);
}
}
function shop(): string[] {
const card = new Card();
const gift = new GiftCard();
return [
new Checkout().pay(card, 40), // card charged 40
new Returns().giveBack(card, 40), // card refunded 40
new Checkout().pay(gift, 25), // gift card charged 25
// new Returns().giveBack(gift, 25), // does not compile
];
}Transcript, step by step
Problem
Step 1: A shop that takes cards Before
Payment Method is an abstract class, and every way to pay extends it, so it's the base type. It promises its callers a charge method and a refund method, and that promise is its contract. Card is the one way the shop has to pay, and it can charge and refund. Checkout and Returns are the callers, and they only know the base type. Shop pays forty dollars with the card and gives it back, so it returns card charged and card refunded. Every payment so far could be charged and refunded, so putting both methods in the base type made sense.
Step 2: Finance adds gift cards Code smell
Finance adds gift cards, and a gift card can pay, but its money is never refunded. The quick way adds Gift Card as a subtype of Payment Method, and its charge returns gift card charged. The base type asks for a refund too, so this one throws an Error. Shop pays twenty five with a gift card, then gives it back. The compiler accepts every line, so what happens when this last line runs? It throws, because give back calls refund here, and refund throws here.
Step 3: Returns checks the type Code smell
To stop the crash, you'd put a type check at the top of give back. If the method is a Gift Card, it returns gift card no refund right away. So shop runs to the end again, with two gift card lines in its result.
Step 4: A subtype breaks its contract Code smell
Gift Card is a subtype that can't keep its contract, so a caller that trusts the base type crashed. The contract includes refund here, the gift card breaks it here, and Returns now needs a type check here. The next payment that can't refund needs a second check here, and every other caller of refund needs its own. And the base type still says refund is safe to call on any payment, which is no longer true.
Step 5: Liskov Substitution Principle
So code that works with a base type must keep working with any subtype, and that rule is the Liskov Substitution Principle. Gift Card breaks it, and the type check in Returns is the symptom.
Refactor
Step 6: Refactor first Back it out
So let's take the gift card and the type check out again. We'll refactor first, so the contract fits every payment, and then add gift cards back.
Step 7: Name the refund Extract Interface
First we give refund a type of its own, an interface called Refundable with that one method. TypeScript matches types by shape, so Card already fits Refundable. Writing implements Refundable says so to the reader, and the compiler checks that it holds. Payment Method still declares refund, and the next two moves fix that.
Step 8: Returns asks for Refundable Change Function Declaration
Next, give back takes a Refundable instead of a Payment Method, because refund is the only thing it calls. Shop still passes the card, and a card is a Refundable, so it returns the same two lines.
Step 9: Refund leaves the base type Push Down Method
Now no caller needs refund from Payment Method, so it moves down out of the base type. The contract of Payment Method is charge only, and refund lives in Refundable, which Card implements.
Payoff
Step 10: Gift cards, again Payoff
So now gift cards can come back in. Gift Card extends Payment Method with charge only, so there's no refund method and nothing to throw. Shop pays twenty five with it, and that line returns gift card charged twenty five. The give back line from before stays here as a comment, because it would not compile. Gift Card has no refund method, so its shape doesn't fit Refundable, and the compiler rejects that argument. That beats the crash, because the error names the calling line before any code runs.
Step 11: Every subtype keeps the contract Payoff
Card, Checkout and Returns did not change to add gift cards. The contract has no refund here, the gift card has nothing to break here, and Returns has no type check here. Gift Card can stand in for Payment Method everywhere, because the contract holds only what every payment can do. But the split has a cost, because code that holds only a Payment Method can't refund it. So any code that needs a refund has to ask for a Refundable, the way Returns does.
Recap
Step 12: Where we started, where we ended Before / after
On the left, Payment Method's contract included refund, so the first payment that couldn't refund had to throw. On the right, its contract is charge only, and refund has its own interface. Gift Card keeps the whole contract it inherits, and Returns asks only for something it can refund.
Step 13: When to reach for it Takeaways
Reach for it when a subtype overrides a method only to throw, or when its callers need a special case for it. Skip it when the subtype can keep the contract, like gift cards that pay back in store credit. You'll spot it by a caller with a type check, or an override that throws for good. So a function that only reads a list asks for a Readonly Array, and every array you have fits.