Published on

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

📘 Mastering the Interface Segregation Principle (ISP) in TypeScript

The Interface Segregation Principle (ISP) is the I in SOLID—a set of principles for designing robust, clean, and flexible object-oriented software.

🧠 What Is the Interface Segregation Principle?

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

This means that large interfaces should be broken down into smaller, more specific ones, so that classes only implement the behavior they actually need.

❌ 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

BenefitDescription
🧼 Cleaner CodeSmaller interfaces mean less clutter and easier understanding
🧪 Better TestabilityClasses only implement what they use—less mocking and setup
🔗 Loose CouplingClients depend only on what they need
📦 Reusable InterfacesInterfaces become reusable in other contexts
🔄 FlexibilityEasier 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

AspectDescription
❓ What is ISPClients shouldn't depend on interfaces they don't use
🧨 Violation ExampleMonolithic Machine or CloudServiceProvider interfaces
✅ Good PracticeSmaller interfaces like Printer, Scanner, Backuper
🛠 How to ApplyIdentify fat interfaces, split based on capabilities
📈 BenefitsCleaner 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

SymptomLikely Cause
Many throw new Error('Not supported')Interface too broad
Lots of if (plugin.feature) in codeFeature segregation needed
Changes to interfaces break unrelated modulesInterface is shared too widely
Tests mock irrelevant methodsOverexposed 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

BenefitExplanation
🧼 No BloatClasses only implement what they use
🔍 Easier DebuggingLess risk of method misuse or runtime crashes
🧪 Better TestingFewer mocks, smaller tests, better confidence
♻️ Clean ExtensibilityAdd new plugin types without modifying existing code
🚀 Dynamic FlexibilityRuntime capabilities are safely discoverable

📌 Summary

TopicDescription
🔧 What is ISPClasses shouldn't be forced to implement interfaces they don't use
❌ Bad ExampleOne Plugin interface forces all plugins to implement everything
✅ Good ExampleSmall capability interfaces, dynamic safe plugin architecture
🛠 DetectionLook for throw, if, broken tests, bloated mocks
🔄 ApplicationInterface 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?