Skip to content
ExponaBLOG
DESIGN PATTERNS IN PRACTICE · DAY 005 / 100

Liskov substitution in plain words: a step-by-step guide

If it looks like a duck but needs batteries, your abstraction is wrong.

Expona Admin9 min read

You've built a payment processor that handles credit cards, digital wallets, and bank transfers. Everything works until someone tries to use a payment method that doesn't support refunds—and your code crashes because it expects all payment handlers to honor a refund contract. This is where Liskov substitution in plain words saves you: if a subclass claims to be a payment handler, it must behave like one, no exceptions.

Understanding the Liskov Substitution Principle

The Liskov Substitution Principle (LSP) states that objects of a superclass should be replaceable with objects of its subclasses without breaking the application. In simpler terms: if your code calls a method on a parent type, any child type must do what the parent promised, not something unexpected.

I think of it this way: imagine you hire a carpenter to build a door frame. You expect that frame to be rectangular, hold a standard door, and support the weight of that door. If the carpenter delivers a frame that's circular, won't hold weight, or only works on Tuesdays, they've violated your contract—they're not a proper carpenter substitute.

LSP isn't just about having the right method signatures. It's about behavior, assumptions, and contracts. A subclass can be more permissive (accept broader inputs), but it can never be more restrictive (reject valid inputs). And it must never violate the guarantees the parent promised.

Subclasses Must Honor the Parent's Contract

Let's say you're building a repository pattern for data access. The parent interface promises: "I will return all users, sorted by creation date, or throw a specific exception if the database is unreachable."

typescript
abstract class UserRepository {
  abstract getAll(): Promise<User[]>;
  abstract getById(id: string): Promise<User | null>;
}

class SqlUserRepository extends UserRepository {
  async getAll(): Promise<User[]> {
    const users = await this.db.query('SELECT * FROM users ORDER BY created_at');
    return users;
  }

  async getById(id: string): Promise<User | null> {
    const user = await this.db.queryOne('SELECT * FROM users WHERE id = ?', [id]);
    return user || null;
  }
}

This works. Now someone adds a cache layer:

typescript
class CachedUserRepository extends UserRepository {
  constructor(private cache: Cache, private db: SqlUserRepository) {}

  async getAll(): Promise<User[]> {
    const cached = await this.cache.get('users:all');
    if (cached) return cached;
    
    const users = await this.db.getAll();
    await this.cache.set('users:all', users, 3600);
    return users;
  }

  async getById(id: string): Promise<User | null> {
    const key = `user:${id}`;
    const cached = await this.cache.get(key);
    if (cached) return cached;
    
    const user = await this.db.getById(id);
    if (user) await this.cache.set(key, user, 3600);
    return user;
  }
}

Good. The contract is honored: callers get the same behavior whether they use SqlUserRepository or CachedUserRepository. Both return sorted users, both return null for missing IDs. Both fail the same way if the database is down.

But here's where it breaks:

typescript
class ReadOnlyUserRepository extends UserRepository {
  async getAll(): Promise<User[]> {
    return []; // Always return empty array?
  }

  async getById(id: string): Promise<User | null> {
    throw new Error('Read-only repository');
  }
}

Now code that worked with SqlUserRepository crashes with ReadOnlyUserRepository. The contract is broken. The parent promised "get a user by ID," and the child refuses. This is LSP violation.

Throwing NotSupported in Overrides is a Smell

One of the easiest ways to spot an LSP violation is seeing NotImplementedException, NotSupportedError, or similar in a subclass override.

typescript
interface PaymentMethod {
  charge(amount: number): Promise<void>;
  refund(transactionId: string, amount: number): Promise<void>;
}

class CreditCardPayment implements PaymentMethod {
  async charge(amount: number): Promise<void> {
    // Actually charge the card
  }

  async refund(transactionId: string, amount: number): Promise<void> {
    // Process refund
  }
}

class CashPayment implements PaymentMethod {
  async charge(amount: number): Promise<void> {
    // Mark as paid
  }

  async refund(transactionId: string, amount: number): Promise<void> {
    throw new Error('Cash payments cannot be refunded online');
  }
}

Now a billing service calls refund on any PaymentMethod:

typescript
async function processRefund(method: PaymentMethod, txId: string, amount: number) {
  await method.refund(txId, amount); // Crashes if it's CashPayment
}

The smell is the exception. CashPayment violates the contract that PaymentMethod promised.

The fix isn't to silently ignore refund requests. It's to split the interface:

typescript
interface PaymentMethod {
  charge(amount: number): Promise<void>;
}

interface RefundablePayment extends PaymentMethod {
  refund(transactionId: string, amount: number): Promise<void>;
}

class CreditCardPayment implements RefundablePayment {
  async charge(amount: number): Promise<void> {
    // Charge card
  }

  async refund(transactionId: string, amount: number): Promise<void> {
    // Process refund
  }
}

class CashPayment implements PaymentMethod {
  async charge(amount: number): Promise<void> {
    // Mark as paid
  }
}

Now callers that need refunds explicitly ask for RefundablePayment. No surprises, no violations:

typescript
async function processRefund(method: RefundablePayment, txId: string, amount: number) {
  await method.refund(txId, amount); // Safe—only RefundablePayment types here
}

I've seen real codebases where developers added notImplemented() methods and hoped they'd never be called. They always are. Always.

Prefer Smaller Interfaces Over Forced Inheritance

A common pattern I've seen (and done myself) is creating one giant interface and forcing everything to implement it, then throwing "not supported" in methods that don't make sense.

typescript
// Bad: Too many responsibilities in one interface
interface DataStore {
  create(item: any): Promise<void>;
  read(id: string): Promise<any>;
  update(id: string, item: any): Promise<void>;
  delete(id: string): Promise<void>;
  batchDelete(ids: string[]): Promise<void>;
  search(query: string): Promise<any[]>;
  export(format: 'json' | 'csv'): Promise<Buffer>;
  import(data: Buffer): Promise<void>;
}

An in-memory cache now has to implement export/import, which makes no sense. A read-only audit log has to implement delete. This forces bad behavior.

Instead, compose smaller interfaces:

typescript
interface Readable<T> {
  read(id: string): Promise<T | null>;
}

interface Writable<T> {
  create(item: T): Promise<void>;
  update(id: string, item: T): Promise<void>;
}

interface Deletable {
  delete(id: string): Promise<void>;
}

interface Searchable<T> {
  search(query: string): Promise<T[]>;
}

interface Exportable {
  export(format: 'json' | 'csv'): Promise<Buffer>;
}

// A read-only audit log
class AuditLog implements Readable<AuditEntry>, Searchable<AuditEntry> {
  async read(id: string): Promise<AuditEntry | null> { }
  async search(query: string): Promise<AuditEntry[]> { }
}

// A writable user database
class UserStore implements Readable<User>, Writable<User>, Deletable {
  async read(id: string): Promise<User | null> { }
  async create(item: User): Promise<void> { }
  async update(id: string, item: User): Promise<void> { }
  async delete(id: string): Promise<void> { }
}

Now each class only promises what it can deliver. No violations, no surprises.

Test Subclasses With the Parent's Tests

Here's a practical way to catch LSP violations before production: run the parent type's tests against every subclass.

Start with a parent contract test:

typescript
abstract class UserRepositoryTests {
  protected repo: UserRepository;

  async testGetAllReturnsSorted() {
    const user1 = await this.repo.create({ name: 'Alice', createdAt: new Date() });
    const user2 = await this.repo.create({ name: 'Bob', createdAt: new Date() });
    
    const all = await this.repo.getAll();
    expect(all[0].createdAt).toBeLessThanOrEqual(all[1].createdAt);
  }

  async testGetByIdReturnsExactUser() {
    const user = await this.repo.create({ name: 'Alice' });
    const fetched = await this.repo.getById(user.id);
    
    expect(fetched?.id).toBe(user.id);
  }

  async testGetByIdReturnsNullForMissing() {
    const result = await this.repo.getById('nonexistent-id');
    expect(result).toBeNull();
  }
}

class SqlRepositoryTests extends UserRepositoryTests {
  beforeEach() {
    this.repo = new SqlUserRepository();
  }
}

class CachedRepositoryTests extends UserRepositoryTests {
  beforeEach() {
    this.repo = new CachedUserRepository();
  }
}

Both SqlRepositoryTests and CachedRepositoryTests run the same contract tests. If CachedUserRepository violates the parent's promises (e.g., returns unsorted results, or returns a cached user from yesterday), the tests fail. This catches violations early.

I've used this pattern in production systems handling millions of transactions. It catches edge cases that code review alone misses.

Common Mistakes

  • Ignoring return type covariance: A subclass can return a more specific type (e.g., Dog instead of Animal), but not a less specific one. This is allowed by LSP.
  • Weakening preconditions: If the parent says "only accept positive numbers," the child can accept negative too (more permissive). But if the child rejects positive numbers, it violates LSP.
  • Strengthening postconditions: If the parent promises "return between 0 and 100," the child can't promise "return exactly 50." The caller may depend on the range.
  • Assuming "override" means "customize": Override means substitute, not redefine. If you need different behavior, your abstraction is wrong.
  • Forgetting side effects: A method that claims to be read-only but modifies a cache violates LSP. The contract includes "side effect free."

Key Takeaways

  • LSP means subclasses must honor the parent's contract completely—no exceptions, no shortcuts.
  • If you're tempted to throw "not supported" in an override, split the interface instead. That smell means your abstraction is too broad.
  • Smaller, focused interfaces are easier to substitute correctly than large, bloated ones.
  • Test every subclass with the parent's test suite. This catches violations before they hit production.
  • LSP violations often hide as silent bugs: code that works with one subclass crashes or behaves unexpectedly with another.
  • Think in terms of contracts and promises, not just method signatures. A subclass that satisfies LSP does what callers expect, not what you think is clever.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles