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
}
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')
}
}
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()
}
}
Works. But then someone writes "movie mode":
export function movieMode(devices: SmartDevice[]) {
for (const device of devices) {
device.setBrightness(20)
device.play('intro.mp3')
}
}
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
}
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}%`)
}
}
// 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')
}
}
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')
}
}
// 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])
Output:
💡 brightness 20%
🔊 playing intro.mp3
💡 lamp off
🔊 speaker off
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
}
// src/door-lock.ts
import type { Lockable } from './capabilities.js'
export class DoorLock implements Lockable {
lock() {
console.log('🔒 locked')
}
unlock() {
console.log('🔓 unlocked')
}
}
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 withturnOnandturnOff. 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)