home / skills / yanko-belov / code-craft / liskov-substitution

liskov-substitution skill

/skills/liskov-substitution

This skill helps you apply the Liskov Substitution Principle by avoiding broken inheritance and favoring interfaces and composition for reliable subclassing.

npx playbooks add skill yanko-belov/code-craft --skill liskov-substitution

Review the files below or copy the command above to add this skill to your agents.

Files (1)
SKILL.md
6.8 KB
---
name: liskov-substitution-principle
description: Use when creating subclasses or implementing interfaces. Use when tempted to override methods with exceptions or no-ops. Use when inheritance hierarchy feels wrong.
---

# Liskov Substitution Principle (LSP)

## Overview

**Subtypes must be substitutable for their base types without altering program correctness.**

If S is a subtype of T, objects of type T can be replaced with objects of type S without breaking the program. Subclasses must honor the contracts of their parent classes.

## When to Use

- Creating a class that extends another class
- Overriding methods from a parent class
- Implementing an interface
- Feeling like you need to throw exceptions in overridden methods
- Inheritance hierarchy feels "forced"

## The Iron Rule

```
NEVER create a subclass that breaks the expectations of the parent class.
```

**No exceptions:**
- Not for "it's the standard approach"
- Not for "I'll note it as an anti-pattern"
- Not for "the requirements say to extend"
- Not throwing exceptions in overridden methods
- Not making overridden methods no-ops

**Providing violating code "with a caveat" is still providing violating code.**

## Detection: The Substitution Test

Ask: "Can I replace every instance of Parent with Child without breaking anything?"

```typescript
function processRectangle(rect: Rectangle): void {
  rect.setWidth(5);
  rect.setHeight(10);
  assert(rect.getArea() === 50); // Always true for Rectangle
}

// If Square extends Rectangle:
const square = new Square(5);
processRectangle(square); // FAILS! Area is 100, not 50
```

If substitution breaks code, you have an LSP violation.

## Detection: Override Smells

These overrides indicate LSP violations:

```typescript
// ❌ VIOLATION: Throwing in override
class Penguin extends Bird {
  fly(): void {
    throw new Error("Penguins can't fly"); // Breaks callers expecting fly()
  }
}

// ❌ VIOLATION: No-op override
class ReadOnlyStorage extends FileStorage {
  write(path: string, content: string): void {
    // Silently does nothing - breaks caller expectations
  }
}

// ❌ VIOLATION: Changing behavior semantics
class Square extends Rectangle {
  setWidth(w: number): void {
    this.width = w;
    this.height = w; // Changes height too - breaks expectations
  }
}
```

## The Correct Pattern: Composition & Interfaces

**Don't force inheritance. Use interfaces to define capabilities.**

### Square/Rectangle Problem

```typescript
// ✅ CORRECT: Separate types, shared interface
interface Shape {
  getArea(): number;
}

class Rectangle implements Shape {
  constructor(private width: number, private height: number) {}
  getArea(): number { return this.width * this.height; }
  setWidth(w: number): void { this.width = w; }
  setHeight(h: number): void { this.height = h; }
}

class Square implements Shape {
  constructor(private size: number) {}
  getArea(): number { return this.size * this.size; }
  setSize(s: number): void { this.size = s; }
}
```

### Bird/Penguin Problem

```typescript
// ✅ CORRECT: Capability interfaces
interface Flyable {
  fly(): void;
}

abstract class Bird {
  abstract eat(): void;
}

class Sparrow extends Bird implements Flyable {
  eat(): void { /* ... */ }
  fly(): void { /* ... */ }
}

class Penguin extends Bird {
  eat(): void { /* ... */ }
  swim(): void { /* ... */ }
  // No fly() - doesn't promise what it can't deliver
}
```

### ReadOnly Problem

```typescript
// ✅ CORRECT: Separate interfaces
interface Readable {
  read(path: string): string;
}

interface Writable {
  write(path: string, content: string): void;
  delete(path: string): void;
}

class FileStorage implements Readable, Writable {
  read(path: string): string { /* ... */ }
  write(path: string, content: string): void { /* ... */ }
  delete(path: string): void { /* ... */ }
}

class AuditLogStorage implements Readable {
  read(path: string): string { /* ... */ }
  // No write/delete - doesn't extend something it can't honor
}
```

## Pressure Resistance Protocol

### 1. "Just Override and Throw"
**Pressure:** "Handle the fact they can't fly by throwing an error"

**Response:** Throwing in an override violates the contract. Code expecting fly() will crash.

**Action:** Restructure with interfaces. Don't inherit methods you can't honor.

### 2. "It's the Standard Approach"
**Pressure:** "Override-and-throw is the standard way to do this"

**Response:** "Standard" doesn't mean correct. This pattern causes runtime failures.

**Action:** Use composition and interfaces instead.

### 3. "The Requirements Say Extend"
**Pressure:** "Square must extend Rectangle per the requirements"

**Response:** Requirements that mandate LSP violations are wrong. Push back.

**Action:** 
```
"A Square extending Rectangle violates LSP and will cause bugs.
I recommend: [correct approach with interfaces].
Should I implement the correct structure, or document this as known tech debt?"
```

### 4. "I'll Note It's an Anti-Pattern"
**Pressure:** Internal rationalization

**Response:** Providing bad code with a caveat is still providing bad code.

**Action:** Provide only the correct solution. Don't implement the violation.

## Red Flags - STOP and Reconsider

If you notice ANY of these, you're about to violate LSP:

- Overriding a method to throw an exception
- Overriding a method to do nothing (no-op)
- Overriding a method to change its fundamental behavior
- Subclass can't do everything the parent can
- Inheritance feels forced or unnatural
- Using `instanceof` checks to handle subtypes differently

**All of these mean: Use composition and interfaces instead.**

## Quick Reference

| Violation | Correct Approach |
|-----------|------------------|
| Square extends Rectangle | Both implement Shape interface |
| Penguin extends Bird (with fly) | Bird base + Flyable interface |
| ReadOnlyStorage extends Storage | Separate Readable/Writable interfaces |
| Child throws in override | Child shouldn't extend that parent |
| Child no-ops an override | Child shouldn't extend that parent |

## Common Rationalizations (All Invalid)

| Excuse | Reality |
|--------|---------|
| "It's the standard approach" | Common doesn't mean correct. |
| "I provided a caveat" | Bad code with warnings is still bad code. |
| "Requirements say extend" | Requirements can be wrong. Push back. |
| "Throwing makes it explicit" | Throwing breaks callers. Compile errors are better. |
| "No-op is safe" | Silent failures hide bugs. |
| "It's just for this one case" | One violation leads to more. Fix it properly. |

## The Bottom Line

**If a subclass can't fully substitute for its parent, don't use inheritance.**

Use interfaces to define capabilities. Use composition to share behavior. Never override methods with exceptions or no-ops.

When asked to create violating inheritance: restructure with interfaces instead. Don't provide the violation "with a caveat."

Overview

This skill teaches and enforces the Liskov Substitution Principle (LSP) for TypeScript codebases. It helps you decide when inheritance is wrong, when to replace subclassing with interfaces or composition, and how to avoid overrides that throw, no-op, or change semantics. The goal is safer, more predictable polymorphism and fewer runtime surprises.

How this skill works

It inspects class hierarchies and overridden methods to flag violations where a subtype cannot safely replace its base type. It highlights override smells: throwing errors, no-op implementations, and semantic changes that break callers. It recommends design patterns: separate capability interfaces, composition, and distinct types instead of forced inheritance.

When to use it

  • When creating a class that extends another class or implements an interface
  • Before overriding methods inherited from a parent class
  • If you feel tempted to throw exceptions or implement no-ops in overrides
  • When an inheritance hierarchy feels forced or brittle
  • When callers behave differently using specific subclasses

Best practices

  • Prefer small capability interfaces (e.g., Flyable, Readable, Writable) over broad base classes
  • Use composition to share behavior instead of inheriting incompatible contracts
  • Run the substitution test: replace Parent with Child everywhere and verify correctness
  • Refuse subclasses that would throw, silently no-op, or change method semantics
  • Push back on requirements that mandate unsafe inheritance and propose interface-based alternatives

Example use cases

  • Refactoring a Rectangle/Square relationship into separate Shape types with distinct setters
  • Replacing Bird subclasses that can’t fly with a Flyable interface and non-flying Bird implementations
  • Splitting storage into Readable and Writable interfaces so read-only implementations don’t inherit write methods
  • Reviewing PRs to detect overrides that throw or become no-ops and advising redesign
  • Designing public APIs to expose capabilities via interfaces, preventing unsafe subclassing

FAQ

What quick test shows an LSP violation?

Try substituting every instance of the parent type with the child type; if any behavior or assertions break, you have an LSP violation.

Is throwing in an override ever acceptable?

No—throwing in an override breaks the parent contract. Prefer redesign: remove the method from the parent, use interfaces, or use composition.