DEV Community

Christian Vasquez
Christian Vasquez

Posted on

How I finally understood the Liskov Substitution Principle

The Intro

Halfway through SOLID! We are at the L, the one with the scary name: the Liskov Substitution Principle.

If the previous two felt like common sense with fancy labels, this one is the first that usually catches people off guard. I have shipped the exact bug we are about to write. More than once. 🙃

The What

The original definition is a math paper from 1987 and reads like one:

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.

Let me translate: if your code works with the parent, it must also work with any child, without surprises. No instanceof checks, no "oh but not that one".

The usual example is squares and rectangles. I have never once had a square in production, so let's do something you will actually run into: payments and refunds.

The How

Our store accepts a few ways to pay. Each one can charge and refund:

// src/payment-method.ts
export abstract class PaymentMethod {
  abstract charge(amount: number): Promise<void>
  abstract refund(amount: number): Promise<void>
}
Enter fullscreen mode Exit fullscreen mode
// src/card-payment.ts
import { PaymentMethod } from './payment-method.js'

export class CardPayment extends PaymentMethod {
  async charge(amount: number) {
    console.log(`💳 charged $${amount}`)
  }

  async refund(amount: number) {
    console.log(`💳 refunded $${amount}`)
  }
}
Enter fullscreen mode Exit fullscreen mode
// src/paypal-payment.ts
import { PaymentMethod } from './payment-method.js'

export class PaypalPayment extends PaymentMethod {
  async charge(amount: number) {
    console.log(`🅿️ charged $${amount}`)
  }

  async refund(amount: number) {
    console.log(`🅿️ refunded $${amount}`)
  }
}
Enter fullscreen mode Exit fullscreen mode

And the part that uses them never cares which one it got:

// src/refund-service.ts
import type { PaymentMethod } from './payment-method.js'

interface Order {
  id: string
  total: number
  paidWith: PaymentMethod
}

export class RefundService {
  async refundAll(orders: Order[]) {
    for (const order of orders) {
      await order.paidWith.refund(order.total)
      console.log(`✅ order ${order.id} refunded`)
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Beautiful. Substitutable. Barbara Liskov would be proud.

Then product adds gift cards. Gift cards can be charged, obviously. But legal says gift cards can't be refunded to cash. So we write the "obvious" thing:

// src/gift-card-payment.ts
import { PaymentMethod } from './payment-method.js'

export class GiftCardPayment extends PaymentMethod {
  async charge(amount: number) {
    console.log(`🎁 charged $${amount}`)
  }

  async refund() {
    throw new Error('Gift cards cannot be refunded')
  }
}
Enter fullscreen mode Exit fullscreen mode

It compiles. TypeScript is happy. The PR gets approved. 🚢

Three weeks later a product gets recalled and support runs the "refund everyone who bought it" script:

// src/main.ts
import { RefundService } from './refund-service.js'
import { CardPayment } from './card-payment.js'
import { GiftCardPayment } from './gift-card-payment.js'

const orders = [
  { id: 'A1', total: 40, paidWith: new CardPayment() },
  { id: 'A2', total: 25, paidWith: new GiftCardPayment() },
  { id: 'A3', total: 60, paidWith: new CardPayment() },
]

await new RefundService().refundAll(orders)
Enter fullscreen mode Exit fullscreen mode

Output:

💳 refunded $40
✅ order A1 refunded
Error: Gift cards cannot be refunded
Enter fullscreen mode Exit fullscreen mode

Order A1 refunded. Order A2 blew up. Order A3? Never happened. Customer A3 is now writing a one-star review.

That's a Liskov violation. RefundService was written against PaymentMethod, and the contract of PaymentMethod says "I can refund". GiftCardPayment said it was a PaymentMethod and then broke the promise at runtime.

The usual "fix" makes it worse:

if (order.paidWith instanceof GiftCardPayment) continue
Enter fullscreen mode Exit fullscreen mode

Now RefundService knows about gift cards. Tomorrow it has to know about store credit. Then crypto. The whole point of the base class was to not know.

Step 1: stop lying in the type

The real problem is that the parent promised something not every child can keep. So let's make the promise smaller:

// src/payment-method.ts
export interface PaymentMethod {
  charge(amount: number): Promise<void>
}

export interface Refundable {
  refund(amount: number): Promise<void>
}
Enter fullscreen mode Exit fullscreen mode

(Yes, we swapped the abstract class for interfaces. There was no shared code in the base anyway.)

Step 2: each method tells the truth

// src/card-payment.ts
import type { PaymentMethod, Refundable } from './payment-method.js'

export class CardPayment implements PaymentMethod, Refundable {
  async charge(amount: number) {
    console.log(`💳 charged $${amount}`)
  }

  async refund(amount: number) {
    console.log(`💳 refunded $${amount}`)
  }
}
Enter fullscreen mode Exit fullscreen mode
// src/gift-card-payment.ts
import type { PaymentMethod } from './payment-method.js'

export class GiftCardPayment implements PaymentMethod {
  async charge(amount: number) {
    console.log(`🎁 charged $${amount}`)
  }
}
Enter fullscreen mode Exit fullscreen mode

No refund() on the gift card. No throw. It just doesn't claim to be something it isn't.

Step 3: ask for exactly what you need

// src/refund-service.ts
import type { Refundable } from './payment-method.js'

interface RefundableOrder {
  id: string
  total: number
  paidWith: Refundable
}

export class RefundService {
  async refundAll(orders: RefundableOrder[]) {
    for (const order of orders) {
      await order.paidWith.refund(order.total)
      console.log(`✅ order ${order.id} refunded`)
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

And now the best part. Go back to main.ts and try to pass a gift card order into refundAll():

Type 'GiftCardPayment' is not assignable to type 'Refundable'.
  Property 'refund' is missing in type 'GiftCardPayment'.
Enter fullscreen mode Exit fullscreen mode

The compiler caught it. At build time. Not at 2 PM on recall day.

Whoever writes the refund script now has to decide up front what to do with gift card orders (issue store credit, email the customer, whatever). The decision moved from "a crash in a loop" to "a red squiggle in the editor".

The Why

Let's be explicit about the lesson, because it is subtle:

  • Inheritance is a promise, not a shortcut. "GiftCard is a PaymentMethod" sounded right in English. In code, it meant "GiftCard can do everything PaymentMethod can do", and that was false.
  • A method that throws NotSupported is a red flag. If a child has to disable a parent's method, the parent's contract is too big. Shrink it.
  • Callers depend on the smallest thing they need. RefundService needs refunds, so it asks for Refundable. Not PaymentMethod. Not CardPayment.
  • The type system becomes your QA. Every wrong combination fails to compile instead of failing in production.

If you remember one sentence: a subclass may do more than its parent, never less.

The Goodbye

Did you notice that fixing the L made us split one fat interface into two thin ones? That's not a coincidence. That's the I (Interface Segregation) knocking on the door, and it's up next.

Go search your codebase for throw new Error('not supported'). I'll wait. Happy coding 🤝

Top comments (1)

Collapse
 
lilnickyai profile image
Lil Nicky AI •

The gift card example makes this click in a way the square and rectangle example never did for me. A subclass that throws for a method it inherited is not a small exception, it is a broken promise to every caller that trusted the parent type. This is the check I use when I review code: run the same caller code against every subclass and check that none of them needs a special case. If one does, shrink the parent contract first, just as you did with Refundable, instead of adding an instanceof check to hide the difference.