LearnLLDSOLIDI: Interface segregation

Interface segregation principle

The restaurant app had one Staff interface: cook, serve, deliver. When delivery riders were added, Rider implements Staff needed all three, so two of them were written as throw new Error('Riders do not cook'). Then someone added cleanTable to Staff. Eleven classes stopped compiling. Nine of them got one more method that throws.

Nobody made a wrong decision that day. The interface was wrong from the start, and every class that touched it paid for it.

Your answer

A Staff interface has cook(), serve() and deliver(). You have chefs, waiters, riders, and an owner who does all three on a busy day. How would you break the interface up, and what would the dispatch function that assigns deliveries ask for?

The idea

The picture to keep
The TV remote

It has 47 buttons. Your grandmother uses four: power, volume up, volume down, and the one for her serial. The other 43 are things to press by mistake and then call you about. What she needs is a remote with four buttons. The TV can still support all 47 for the person who wants them.

In one line: Many small interfaces beat one big one. Nobody should depend on a method they do not use.

The principle is usually stated from the caller’s side: a client should not be forced to depend on methods it does not use. It cuts both ways. The class implementing a fat interface writes methods it has no business having. And the function receiving one can see, and can call, far more than it needs.

Before: one interface for everyone

interface Staff {
  cook(dish: string): string
  serve(table: number): string
  deliver(address: string): string
}
 
class Rider implements Staff {
  cook(dish: string): string {
    throw new Error('Riders do not cook')
  }
 
  serve(table: number): string {
    throw new Error('Riders do not serve tables')
  }
 
  deliver(address: string): string {
    return `Delivered to ${address}`
  }
}
 
function dispatch(staff: Staff, address: string): string {
  return staff.deliver(address)
}
 
console.log(dispatch(new Rider(), 'HSR Layout')) // Delivered to HSR Layout

Two problems, one on each side of the interface. Rider has two methods that exist only to throw, which is the Liskov violation from the last page. And dispatch asks for a whole Staff when it calls one method, so nothing stops the next developer from calling staff.cook() in there and finding out at runtime.

After: one interface per role

interface Cook {
  cook(dish: string): string
}
 
interface Server {
  serve(table: number): string
}
 
interface Courier {
  deliver(address: string): string
}
 
class Chef implements Cook {
  cook(dish: string): string {
    return `Cooked ${dish}`
  }
}
 
class Rider implements Courier {
  deliver(address: string): string {
    return `Delivered to ${address}`
  }
}
 
class Owner implements Cook, Server, Courier {
  cook(dish: string): string {
    return `Owner cooked ${dish}`
  }
 
  serve(table: number): string {
    return `Owner served table ${table}`
  }
 
  deliver(address: string): string {
    return `Owner delivered to ${address}`
  }
}
 
function dispatch(courier: Courier, address: string): string {
  return courier.deliver(address)
}
 
console.log(dispatch(new Rider(), 'HSR Layout')) // Delivered to HSR Layout
console.log(dispatch(new Owner(), 'Koramangala'))
// > Owner delivered to Koramangala
 
dispatch(new Chef(), 'Indiranagar')
// Error: Argument of type 'Chef' is not assignable to parameter
//   of type 'Courier'.

Rider has one method and it is real. Owner implements three interfaces because the owner really does three jobs. dispatch asks for a Courier, so inside it deliver is the only method there is. And sending a chef out on a delivery is now a compile error.

Adding cleanTable later means a new Cleaner interface. Zero existing classes break.

The interface
Every class implements every method, whether it can do the job or not. dispatch can see cook() and serve() though it will never need them. A change to any one method ripples to every class.

Combining small interfaces in TypeScript

Small interfaces are easy to put back together when one function needs two roles. & is the intersection type: a value that satisfies both.

interface Cook {
  cook(dish: string): string
}
 
interface Server {
  serve(table: number): string
}
 
function runCounter(person: Cook & Server): string {
  return `${person.cook('dosa')}, then ${person.serve(4)}`
}
 
const owner = {
  cook: (dish: string) => `cooked ${dish}`,
  serve: (table: number) => `served table ${table}`,
}
 
console.log(runCounter(owner)) // cooked dosa, then served table 4

You do not need a fourth interface called CookAndServer. The function states exactly what it needs, in its own signature.

And when the fat interface belongs to a library you cannot change, Pick carves a small one out of it:

interface Staff {
  cook(dish: string): string
  serve(table: number): string
  deliver(address: string): string
}
 
type Courier = Pick<Staff, 'deliver'>
 
function dispatch(courier: Courier, address: string): string {
  return courier.deliver(address)
}
 
const cyclist = { deliver: (address: string) => `Delivered to ${address}` }
 
console.log(dispatch(cyclist, 'HSR Layout')) // Delivered to HSR Layout

Pick<Staff, 'deliver'> is a type with only the deliver member of Staff. Because TypeScript is structural, a plain object with one method is enough to call dispatch. Your function depends on one method even though the original interface has three.

One interface per method

Cookable, Servable, Deliverable, Payable, Printable, each with one method, for a class that always uses them together. That is the same mistake in the other direction. Split by role, meaning by who calls what. If every caller that uses read also uses seek, they are one interface. The question to ask is “do I have a client that needs only part of this?”

How is this different from single responsibility?

They sound alike, so expect the question. Single responsibility is about a class and why it changes. Interface segregation is about a contract and who depends on it. A class can have one responsibility and still be seen through several small interfaces by different callers. A file store, for example, is one class that readers see as Readable and writers see as Writable.

TypeScript you just picked up
class Owner implements Cook, Server, Courier
One class, several interfaces. Implement only the ones the class really fulfils.
courier: Courier
Ask for the smallest type that covers what the function uses.
person: Cook & Server
Intersection type. The value must satisfy both interfaces.
Pick<Staff, 'deliver'>
A utility type: a new type holding only the named members of Staff.
Omit<Staff, 'cook'>
The reverse of Pick. Everything in Staff except the named members.
{ deliver: (address: string) => ... }
An object literal can satisfy a small interface. No class needed.

Checkpoint

Checkpoint

1. Rider implements Staff and two of its three methods throw. Which two principles does this break at once?

2. dispatch only calls deliver(). Why does it matter whether its parameter is typed Staff or Courier?

3. A library gives you a 20 method interface and your function needs two of them. What is the TypeScript way to depend on only those two?

Say this in 60 seconds

Interface segregation says no client should be forced to depend on methods it does not use, so I prefer several small role based interfaces to one large one. A fat interface hurts both sides. Implementers end up writing methods that throw or do nothing, and callers can see and call far more than they need, so a change to one method ripples everywhere. I split by role, like cook, server and courier, let a class implement as many as it really fulfils, and have each function ask for the narrowest one. In TypeScript I can combine small interfaces with an intersection type, or cut a small one out of a big one with Pick. I do not go as far as one interface per method. The test is whether some client needs only part of it.

IndGeek provides solutions in the software field, and is a hub for ultimate Tech Knowledge.