DEV Community

Christian Vasquez
Christian Vasquez

Posted on

How I finally understood the Interface Segregation Principle

The Intro

Letter four of five. Last post we broke a fat PaymentMethod into PaymentMethod + Refundable and the compiler started catching our bugs. Today we take that idea and make it the whole show.

The I: Interface Segregation Principle. The one with the longest name and the shortest explanation.

The What

Official words:

No code should be forced to depend on methods it does not use.

That's... actually pretty readable for once. Let me still make it concrete.

An interface is a menu. ISP says: don't hand someone a 40-item menu when they came in for coffee. Give them the coffee menu.

Let's build a smart home to see what happens when the menu gets too big.

The How

We start with a lamp and a speaker. The team decides every device should implement one common interface so the app can treat them all the same. Reasonable!

// src/smart-device.ts
export interface SmartDevice {
  turnOn(): void
  turnOff(): void
  setTemperature(celsius: number): void
  play(track: string): void
  pause(): void
  setBrightness(percent: number): void
}
Enter fullscreen mode Exit fullscreen mode

Now let's implement the lamp:

// src/lamp.ts
import type { SmartDevice } from './smart-device.js'

export class Lamp implements SmartDevice {
  private on = false

  turnOn() {
    this.on = true
    console.log('💡 lamp on')
  }

  turnOff() {
    this.on = false
    console.log('💡 lamp off')
  }

  setBrightness(percent: number) {
    console.log(`💡 brightness ${percent}%`)
  }

  setTemperature() {
    throw new Error('A lamp has no thermostat')
  }

  play() {
    throw new Error('A lamp cannot play music')
  }

  pause() {
    throw new Error('A lamp cannot pause music')
  }
}
Enter fullscreen mode Exit fullscreen mode

Three real methods. Three methods that exist only to throw. The lamp is 50% apology.

The speaker is the same story in reverse: play() and pause() are real, setBrightness() and setTemperature() are throws. And the thermostat implements six methods to use one.

Now the app layer. We want a "good night" routine that turns everything off:

// src/routines.ts
import type { SmartDevice } from './smart-device.js'

export function goodNight(devices: SmartDevice[]) {
  for (const device of devices) {
    device.turnOff()
  }
}
Enter fullscreen mode Exit fullscreen mode

Works. But then someone writes "movie mode":

export function movieMode(devices: SmartDevice[]) {
  for (const device of devices) {
    device.setBrightness(20)
    device.play('intro.mp3')
  }
}
Enter fullscreen mode Exit fullscreen mode

Run it with a lamp in the list and play() throws. Run it with a speaker and setBrightness() throws. The function signature says "give me any SmartDevice" and that's a lie. If you read the last post, you just spotted a Liskov violation. ISP is how we stop creating them.

And one more pain you don't see until month three: add lockDoor() to SmartDevice for the new smart lock and every existing device, including the lamp, has to grow another throwing method. One interface change, twelve files touched. Ouch.

Step 1: split the menu by capability

What can devices actually do? Let's list the verbs and group them:

// src/capabilities.ts
export interface Switchable {
  turnOn(): void
  turnOff(): void
}

export interface Dimmable {
  setBrightness(percent: number): void
}

export interface Thermostat {
  setTemperature(celsius: number): void
}

export interface MediaPlayer {
  play(track: string): void
  pause(): void
}
Enter fullscreen mode Exit fullscreen mode

Four tiny interfaces. Each one is a single idea.

Step 2: devices pick what they can honestly do

// src/lamp.ts
import type { Switchable, Dimmable } from './capabilities.js'

export class Lamp implements Switchable, Dimmable {
  turnOn() {
    console.log('💡 lamp on')
  }

  turnOff() {
    console.log('💡 lamp off')
  }

  setBrightness(percent: number) {
    console.log(`💡 brightness ${percent}%`)
  }
}
Enter fullscreen mode Exit fullscreen mode
// src/speaker.ts
import type { Switchable, MediaPlayer } from './capabilities.js'

export class Speaker implements Switchable, MediaPlayer {
  turnOn() {
    console.log('🔊 speaker on')
  }

  turnOff() {
    console.log('🔊 speaker off')
  }

  play(track: string) {
    console.log(`🔊 playing ${track}`)
  }

  pause() {
    console.log('🔊 paused')
  }
}
Enter fullscreen mode Exit fullscreen mode

Zero throws. The lamp is 100% lamp.

Step 3: routines ask for the capability they use

// src/routines.ts
import type { Switchable, Dimmable, MediaPlayer } from './capabilities.js'

export function goodNight(devices: Switchable[]) {
  for (const device of devices) {
    device.turnOff()
  }
}

export function movieMode(lights: Dimmable[], players: MediaPlayer[]) {
  for (const light of lights) {
    light.setBrightness(20)
  }
  for (const player of players) {
    player.play('intro.mp3')
  }
}
Enter fullscreen mode Exit fullscreen mode
// src/main.ts
import { Lamp } from './lamp.js'
import { Speaker } from './speaker.js'
import { goodNight, movieMode } from './routines.js'

const lamp = new Lamp()
const speaker = new Speaker()

movieMode([lamp], [speaker])
goodNight([lamp, speaker])
Enter fullscreen mode Exit fullscreen mode

Output:

💡 brightness 20%
🔊 playing intro.mp3
💡 lamp off
🔊 speaker off
Enter fullscreen mode Exit fullscreen mode

Try to sneak the speaker into the lights array and TypeScript stops you before you even hit save. 🛑

Step 4: the smart lock test

New device, new capability:

// src/capabilities.ts
export interface Lockable {
  lock(): void
  unlock(): void
}
Enter fullscreen mode Exit fullscreen mode
// src/door-lock.ts
import type { Lockable } from './capabilities.js'

export class DoorLock implements Lockable {
  lock() {
    console.log('🔒 locked')
  }

  unlock() {
    console.log('🔓 unlocked')
  }
}
Enter fullscreen mode Exit fullscreen mode

Files touched besides the new ones: zero. The lamp didn't even blink.

The Why

  • No more apology methods. If a class throws "not supported", the interface was too fat. Split it until every implementation is honest.
  • Functions say exactly what they need. goodNight(devices: Switchable[]) tells you in the signature that it only flips switches. Documentation for free.
  • Adding a capability doesn't ripple. New interface, new class, done. Existing devices are untouched.
  • Tests get tiny. Want to test goodNight()? Pass an object with turnOn and turnOff. You don't need to fake six methods to test two.
  • It is cheap in TypeScript. Interfaces compile to nothing. Four small ones cost exactly as much as one big one: zero bytes.

Rule of thumb: if you can't describe an interface in one short sentence ("something you can switch on and off"), it's probably two interfaces.

The Goodbye

Four down. The final letter is the D, Dependency Inversion, and it's the one that ties the whole series together. If you've read my post about loosely coupled modules you already have a head start.

Until then: find an interface with more than five methods and ask it, politely, what it really wants to be. Happy coding 🤝

Top comments (0)