Why does your subclass pass every test and still break production?
5 min read
The third SOLID principle polices something the compiler cannot see: whether the substitute keeps the promises the original made.
Still at Northbound Coffee, with the payment-method power strip from the last post. The refunds panel does this and asks nobody's permission:
for (const receipt of pendingRefunds) {
const method = methodFor(receipt.methodId);
await method.refund(receipt);
}Gift cards arrive. Since you can't refund money to a gift card, you implement it honestly: refund throws NotSupportedError. You write the test, it asserts the throw, green. What did you just break?
The surprise: the green test was the problem
The refunds panel starts failing with a 500 — and not only for gift cards. The for loop stops at the first gift-card order and the thirty behind it never get refunded.
The uncomfortable part is the test. Writing "I assert that refund throws NotSupportedError" didn't verify a behaviour: it documented a contract violation and signed it. Everyone calling refund does so against the PaymentMethod type, which promises to return money. The gift card said it was a PaymentMethod and then wasn't one.
The intuition: the stand-in at the counter
Elena runs the counter at Northbound Coffee. When she takes holiday a stand-in comes in, and the deal with the customer in line is implicit: they shouldn't notice the change. If the stand-in charges, wraps and refunds the way Elena did, the line keeps moving.
Now picture three different stand-ins:
- One asks for photo ID to pay in cash. Elena never did: they demand more than the original.
- Another accepts refunds but hands out a voucher instead of money. They deliver less than the original promised.
- The third flatly refuses to do refunds. They simply don't do the job of the post.
All three would pass an interview about what they do handle. All three break the line, because the customer arrived with the counter's expectations, not the stand-in's.
That's the Liskov Substitution Principle (LSP): if a type claims to substitute for another, using it in that place must not change whether the program works. Demanding more or promising less than the original breaks it, compiler or no compiler.
Below, the refund line with the third stand-in in it.
The example, step by step
Last post's contract promised two things to everyone using it:
export type PaymentMethod = {
id: string;
charge(cents: number): Promise<Receipt>;
refund(receipt: Receipt): Promise<void>;
};The honest-but-broken implementation is this one, and the compiler accepts it without a word:
export const giftCard: PaymentMethod = {
id: "gift",
charge: (cents) => ledger.debit(cents),
refund: () => {
throw new NotSupportedError("Gift cards cannot be refunded");
},
};It satisfies the shape of the type: the method exists and the signatures line up. It breaks the behaviour that type promised, and TypeScript doesn't reach that far. A type says what you may call, not what happens when you call it.
Fixing it with an if in the caller is tempting and worse: it moves gift-card knowledge into the refunds panel, tomorrow into accounting, and you're rebuilding the seven switches from the last post.
if (receipt.methodId === "gift") continue;
await method.refund(receipt); The fix is making the type honest: if refunding isn't something every payment method knows how to do, it doesn't belong in the shared contract.
export type PaymentMethod = {
id: string;
charge(cents: number): Promise<Receipt>;
};
export type Refundable = PaymentMethod & {
refund(receipt: Receipt): Promise<void>;
};
export const isRefundable = (m: PaymentMethod): m is Refundable =>
"refund" in m;Now the gift card declares what it is, not what it can't do:
for (const receipt of pendingRefunds) {
const method = methodFor(receipt.methodId);
if (!isRefundable(method)) {
await issueStoreCredit(receipt); // a business decision, in the open
continue;
}
await method.refund(receipt);
}The branch still exists, but it no longer asks who are you — it asks what can you do. A new payment method that also can't refund slots in without touching this file.
Your turn
Prepaid cards can be refunded, but they can run out of balance when charging:
export const prepaid: Refundable = {
id: "prepaid",
charge(cents) {
if (cents > this.balance) throw new InsufficientFunds();
return ledger.debit(cents);
},
// refund: ...
};Throwing when there's no balance is legitimate business behaviour. Does it violate LSP or not?
See the test
It depends on whether the contract already allowed charging to fail.
If charge promised "returns a Receipt, or fails if the charge doesn't go
through", prepaid asks for nothing extra: it fails for a new reason inside a
failure mode the caller already handled. No violation.
If charge promised "always returns a Receipt" and no other method ever
failed, prepaid just strengthened the precondition: you now need enough
balance, and the caller had no way to know. That's the stand-in asking for ID.
The fix isn't removing the throw: it's lifting it into the contract so every
caller can see it. A return type that models failure (a result carrying either
success or error) makes it impossible to ignore.
Going deeper
Liskov wasn't talking about inheritance. The formulation comes from a 1987 Barbara Liskov keynote and was made precise in A Behavioral Notion of Subtyping (Liskov and Jeannette Wing, 1994). The operative word is behavioral: the test isn't the class hierarchy but which provable properties of the supertype still hold for the subtype. That's why in TypeScript, with structural typing and no extends in sight, you violate it just as easily — the example above is an object literal.
The three rules, and the fourth nobody quotes. A subtype can't strengthen preconditions (demand more in order to work), can't weaken postconditions (promise less on completion), and must preserve the supertype's invariants. Liskov and Wing add the history constraint: the subtype also can't allow state changes the supertype forbade. That's the real reason MutableList as a subtype of List is a classic problem — not any method signature.
TypeScript lets you break it on purpose, and knows it. strictFunctionTypes checks parameter contravariance, which is half of LSP at the type level… except for members declared with method syntax. This compiles under strict mode:
type A = { handle(x: string | number): void }; // method: bivariant
type B = { handle: (x: string | number) => void }; // property: contravariant
const a: A = { handle(x: string) {} }; // ✅ compiles
const b: B = { handle: (x: string) => {} }; // ❌ errorThe exception is deliberate: without it, much of the ecosystem (starting with the DOM and Array) would stop type-checking. If you want the compiler policing variance on a contract of yours, declare members as function-typed properties, not with method syntax. It's a two-character change that turns a whole class of LSP violations into compile errors.
And a test that "asserts it throws" is a smell, not a net. If one implementation's test asserts behaviour no other implementation of the same type shares, that type has two different inhabitants dressed as one. The cheap countermeasure is a contract test: one battery of assertions run against every implementation of the type. Whatever fails it isn't of that type.
Takeaways
- LSP is about behaviour, not
extends: with structural typing an object literal breaks it just as well as a class hierarchy. - Demanding more or promising less than the original breaks the caller, even when the signatures line up and the compiler stays quiet.
- A method that throws
NotSupportedis a modelling error: the fix is almost always splitting the type, not patching the implementation.
Search your code for a throw new NotImplemented or an empty method inside something that implements an interface — there's your stand-in. And that fix, splitting the contract in two, is exactly what the next post is about.