Logo
Published on

Mastering the Interface Segregation Principle in TypeScript with Real-World Examples

Definition: Clients should not be forced to depend upon interfaces they do not use.

Explanation: Large interfaces should be broken into smaller, more specific ones. This ensures that classes only implement the methods they need.

Key Points

  • Interfaces (design of contracts)
  • Avoid forcing a class to implement irrelevant methods
  • Prevents fat interfaces and unnecessary dependencies
  • Interfaces are small, focused, and tailored to client needs

❌ Bad Example: A Monolithic Interface

Scenario:

You're designing a Machine interface for various devices. Some machines only support print(), but the interface forces implementation of scan() and fax() as well.

interface Machine {
  print(document: string): void;
  scan(document: string): void;
  fax(document: string): void;
}

class OldPrinter implements Machine {
  print(doc: string): void {
    console.log("Printing:", doc);
  }

  scan(doc: string): void {
    throw new Error("Scan not supported"); // ❌
  }

  fax(doc: string): void {
    throw new Error("Fax not supported"); // ❌
  }
}

🚨 Problem:

  • OldPrinter is forced to implement methods it doesn't support.
  • This breaks the ISP.
  • It increases coupling, hinders testability, and pollutes the codebase with unnecessary logic or errors.

✅ Good Example: Applying Interface Segregation Properly

Solution: Break the interface into smaller, specific ones

interface Printer {
  print(document: string): void;
}

interface Scanner {
  scan(document: string): void;
}

interface Fax {
  fax(document: string): void;
}

class OldPrinter implements Printer {
  print(doc: string): void {
    console.log("Printing:", doc);
  }
}

class ModernPrinter implements Printer, Scanner, Fax {
  print(doc: string): void {
    console.log("Printing:", doc);
  }

  scan(doc: string): void {
    console.log("Scanning:", doc);
  }

  fax(doc: string): void {
    console.log("Faxing:", doc);
  }
}

✅ Benefits:

  • OldPrinter only implements Printer—what it actually needs.
  • No unnecessary methods, no dummy implementations.
  • Cleaner, decoupled, easier to maintain and test.

🧭 How to Detect the Need for ISP

Use this checklist:

  • ❓ Are classes throwing NotImplementedException or similar?
  • ❓ Are interfaces growing in methods and complexity over time?
  • ❓ Are clients forced to depend on methods they don't call?
  • ❓ Do your unit tests involve mocking methods that aren't even used?

If yes to any of these → you likely need to refactor using ISP.

🧠 Advanced Real-World Problem: Cloud Service Provider SDK

❌ Bad Code: One Fat Interface

interface CloudServiceProvider {
  upload(): void;
  download(): void;
  transcode(): void;
  stream(): void;
  backup(): void;
}

class BackupOnlyService implements CloudServiceProvider {
  upload() {
    throw new Error("Not supported");
  }
  download() {
    throw new Error("Not supported");
  }
  transcode() {
    throw new Error("Not supported");
  }
  stream() {
    throw new Error("Not supported");
  }
  backup() {
    console.log("Backup successful");
  }
}

🚨 Problem:

  • This is a God interface (anti-pattern).
  • Backup-only clients are forced to implement all methods—even when irrelevant.

✅ Good Code: ISP in Action

interface Uploader {
  upload(): void;
}

interface Downloader {
  download(): void;
}

interface Transcoder {
  transcode(): void;
}

interface Streamer {
  stream(): void;
}

interface Backuper {
  backup(): void;
}

class BackupOnlyService implements Backuper {
  backup(): void {
    console.log("Backup successful");
  }
}

class MediaService implements Uploader, Downloader, Transcoder, Streamer {
  upload() {
    console.log("Uploading media");
  }

  download() {
    console.log("Downloading media");
  }

  transcode() {
    console.log("Transcoding media");
  }

  stream() {
    console.log("Streaming media");
  }
}

🛠 How to Apply ISP in Your Code

  1. Identify "fat" interfaces that are used in many places.
  2. Observe patterns of unused or throw new Error() methods.
  3. Refactor the interface into smaller capability-focused interfaces.
  4. Use composition to combine small interfaces when needed.
  5. Avoid over-abstraction—only split interfaces when you find clear, divergent responsibilities.

📈 Benefits of Following Interface Segregation Principle

Benefit Description
🧼 Cleaner Code Smaller interfaces mean less clutter and easier understanding
🧪 Better Testability Classes only implement what they use—less mocking and setup
🔗 Loose Coupling Clients depend only on what they need
📦 Reusable Interfaces Interfaces become reusable in other contexts
🔄 Flexibility Easier to change or extend without affecting unrelated classes

🧰 Pro Tips for Interface Segregation

  • Think capability, not classification.
  • Combine interfaces using & in TypeScript when needed:
type MultiService = Uploader & Downloader;
  • Review existing interfaces regularly—if new classes keep skipping certain methods, split the interface.

📌 Summary

Aspect Description
❓ What is ISP Clients shouldn't depend on interfaces they don't use
🧨 Violation Example Monolithic Machine or CloudServiceProvider interfaces
✅ Good Practice Smaller interfaces like Printer, Scanner, Backuper
🛠 How to Apply Identify fat interfaces, split based on capabilities
📈 Benefits Cleaner code, testable, flexible, loosely coupled

If you want to scale your TypeScript or backend architecture without drowning in bloated classes or untestable contracts—embrace the Interface Segregation Principle.

Absolutely! Let's go deeper with a more complex and real-world application of the Interface Segregation Principle (ISP)—one that developers often face when building plugin-based systems, microservices SDKs, or modular enterprise applications.

🔥 Advanced ISP Problem: Plugin System for a Code Editor

Scenario:

You're building a code editor (like VSCode) that supports multiple plugins: formatter, linter, debugger, and test runner. Each plugin implements a common Plugin interface.

But not all plugins support all actions. Forcing them into a monolithic interface leads to fragile, bloated code.

Bad Code: Violating Interface Segregation Principle

interface Plugin {
  format(): void;
  lint(): void;
  debug(): void;
  runTests(): void;
}

class FormatterPlugin implements Plugin {
  format() {
    console.log("Code formatted.");
  }

  lint() {
    throw new Error("Linting not supported");
  }

  debug() {
    throw new Error("Debugging not supported");
  }

  runTests() {
    throw new Error("Test running not supported");
  }
}

class DebuggerPlugin implements Plugin {
  format() {
    throw new Error("Formatting not supported");
  }

  lint() {
    throw new Error("Linting not supported");
  }

  debug() {
    console.log("Debugging...");
  }

  runTests() {
    throw new Error("Test running not supported");
  }
}

🚨 Problems:

  • Each plugin is forced to implement methods it doesn't need.
  • Increases chance of runtime exceptions.
  • Complicates unit testing—you must mock methods you don't use.
  • Violates the open/closed principle too—any changes require touching all plugins.

Good Code: Applying Interface Segregation with Plugin Capabilities

Step 1: Create Capability-based interfaces

interface Formattable {
  format(): void;
}

interface Lintable {
  lint(): void;
}

interface Debuggable {
  debug(): void;
}

interface Testable {
  runTests(): void;
}

Step 2: Each plugin implements only what it supports

class FormatterPlugin implements Formattable {
  format() {
    console.log("Code formatted.");
  }
}

class DebuggerPlugin implements Debuggable {
  debug() {
    console.log("Debugging started...");
  }
}

class TestRunnerPlugin implements Testable {
  runTests() {
    console.log("Running tests...");
  }
}

Step 3: Dynamically detect capabilities (e.g., in a plugin manager)

type Plugin = Partial<Formattable & Lintable & Debuggable & Testable>;

function runPluginActions(plugin: Plugin) {
  if (plugin.format) plugin.format();
  if (plugin.lint) plugin.lint();
  if (plugin.debug) plugin.debug();
  if (plugin.runTests) plugin.runTests();
}

🧠 Result:

  • Every plugin now only implements the methods it needs.
  • Code is safe, predictable, and extensible.
  • The Plugin system supports dynamic behavior without polluting every plugin with dummy or error-throwing methods.
  • New capabilities can be added without affecting existing plugins.

🧭 How to Detect ISP Violations in Complex Systems

Symptom Likely Cause
Many throw new Error('Not supported') Interface too broad
Lots of if (plugin.feature) in code Feature segregation needed
Changes to interfaces break unrelated modules Interface is shared too widely
Tests mock irrelevant methods Overexposed contracts

✅ How to Apply ISP in Complex Projects

  1. Group related actions into capability interfaces (e.g., Formattable, Debuggable).
  2. Use TypeScript's type system (Partial, &, in, typeof) to detect and apply features.
  3. Favor composition over inheritance—compose plugins from mixins or behaviors.
  4. Use feature detection instead of forced interfaces in runtime-dynamic systems.

🧰 Pro Tip: Use Type Guards for Safe Access

function isFormattable(plugin: any): plugin is Formattable {
  return typeof plugin.format === "function";
}

if (isFormattable(plugin)) {
  plugin.format();
}

📈 Benefits Recap

Benefit Explanation
🧼 No Bloat Classes only implement what they use
🔍 Easier Debugging Less risk of method misuse or runtime crashes
🧪 Better Testing Fewer mocks, smaller tests, better confidence
♻️ Clean Extensibility Add new plugin types without modifying existing code
🚀 Dynamic Flexibility Runtime capabilities are safely discoverable

📌 Summary

Topic Description
🔧 What is ISP Classes shouldn't be forced to implement interfaces they don't use
❌ Bad Example One Plugin interface forces all plugins to implement everything
✅ Good Example Small capability interfaces, dynamic safe plugin architecture
🛠 Detection Look for throw, if, broken tests, bloated mocks
🔄 Application Interface decomposition, type guards, capability checks

🔗 Final Thoughts

The Interface Segregation Principle becomes invaluable in scalable systems, plugin architectures, SDKs, and any app involving feature variance across modules.

💡 The smaller your interfaces, the safer and stronger your code becomes.

Would you like this example converted into a PDF guide, video explanation, or GitHub playground to explore interactively?