Appearance
9.3.8 — Interface Segregation
The delivery app defines one interface for everything a courier can do:
typescript
interface Courier {
deliver(order: Order): Promise<DeliveryResult>; // (1)
currentLocation(): Promise<GeoPoint>; // (2)
availableSlots(day: Date): TimeSlot[]; // (3)
acceptCash(amount: Money): Promise<void>; // (4)
scanBarcode(code: string): Promise<Parcel>; // (5)
}Five methods, and a bike courier genuinely does all five. Then the company launches drone delivery.
A drone delivers, line (1), and reports its position, line (2). It has no shift schedule, so line (3) is meaningless. It cannot take cash from a customer, so line (4) is impossible. It has no scanner, so line (5) does not exist.
The engineer implementing DroneCourier has no good options, and every option they have is bad in a different way:
typescript
class DroneCourier implements Courier {
async deliver(o: Order) { /* real */ }
async currentLocation() { /* real */ }
availableSlots(_day: Date): TimeSlot[] { return []; } // (1) a lie
async acceptCash(_amount: Money): Promise<void> { // (2) a crash
throw new Error("drones cannot accept cash");
}
async scanBarcode(_code: string): Promise<Parcel> {
throw new Error("not supported"); // (3) another crash
}
}Line (1) returns an empty list, which the scheduling screen will read as "this courier is fully booked" and quietly stop assigning drones anything. Lines (2) and (3) throw, so any code holding a Courier and calling those methods works for bikes and crashes for drones — which is the Liskov violation from 9.3.7, arriving here through a different door.
The type system said DroneCourier is a Courier. Reality said otherwise, and the mismatch was created the moment somebody put five unrelated abilities into one interface.
1. What the principle says
Robert Martin's wording:
No client should be forced to depend on methods it does not use.
There are two victims in that sentence, and most explanations only mention one.
The implementer is forced to supply methods it cannot honour, producing the stubs, lies and crashes above.
The caller is forced to depend on methods it never calls. A scheduling screen that only needs availableSlots currently depends on the whole Courier interface, so a change to scanBarcode recompiles it, re-tests it, and shows up in its pull request for no reason at all.
The second victim is the one the principle was originally written about, and it is the reason this is a coupling principle rather than a tidiness one.
2. The fix: one interface per role
Split by what a caller needs, not by what an object can do:
typescript
interface Deliverer { deliver(order: Order): Promise<DeliveryResult>; } // (1)
interface Locatable { currentLocation(): Promise<GeoPoint>; } // (2)
interface Schedulable { availableSlots(day: Date): TimeSlot[]; } // (3)
interface CashCollector { acceptCash(amount: Money): Promise<void>; } // (4)
class BikeCourier implements Deliverer, Locatable, Schedulable, CashCollector { /* … */ }
class DroneCourier implements Deliverer, Locatable { /* … */ } // (5) honestLines (1) to (4) each name one ability. Line (5) is the payoff: the drone claims exactly what it can do and says nothing about the rest. There are no stubs, no lies and no crashes, because there is nothing to stub.
Now look at what happens to the callers, which is where the real benefit lands:
typescript
function trackOnMap(units: Locatable[]): void { /* … */ } // (1) needs one method
function buildRoster(staff: Schedulable[]): Roster { /* … */ } // (2) needs one methodLine (1) works with bikes, drones, and anything invented next year that can report a position — a van, a partner company's vehicle, a test double that returns a fixed point. It could not care less about cash or barcodes, and a change to either will never recompile it.
Line (2) takes only things with a schedule, so it is impossible to pass it a drone. That is not a runtime check that somebody has to remember to write. The compiler refuses.
That is the strongest form of this principle: a wrong combination stops being something to catch and becomes something you cannot express.
3. The three costs of a fat interface
Naming them precisely is what wins the argument in review.
Implementations lie. Every method an implementation cannot honour becomes a stub that throws, returns a fake value, or silently does nothing. Each of those is a bug waiting for the first caller who trusts the type.
Callers get coupled to things they never touch. A module that uses one method of a ten-method interface depends on all ten. Changing any of the other nine forces it to recompile, retest, and appear in the review. Over a year that is a constant tax paid by people who have no interest in the change.
Test doubles become expensive. To exercise the map screen, you need something that is Locatable. With a fat interface you must supply all ten methods, most of them stubs, and the stubs are exactly the kind of code that silently drifts out of date. With a one-method interface, the fake is one line — and that is a design signal worth listening to, because an interface that is hard to fake is an interface that is too wide.
4. How to find the seams
Do not split by object. Split by caller. This is the single most useful sentence on the page. Look at each place the interface is used and write down which methods that place actually calls. The clusters you get back are the real interfaces.
Applied above, the clusters are obvious: the map screen calls one method, the roster builder calls one, the delivery flow calls one. Nobody, anywhere, calls all five.
Three other signals worth knowing.
Any implementation that throws "not supported" is telling you the interface is too wide. That message is the principle speaking out loud.
Any interface whose name is a thing rather than an ability — Courier, User, Document — is a candidate, because a thing can do many unrelated jobs while an ability is one job. Names ending in -able or -er tend to stay honest.
Any interface where different implementations use different halves of it has an internal split line already, and you can usually see it by looking at which methods are stubbed together.
5. When not to split
This principle is easy to overdo, and the result is unpleasant in its own way.
Do not split abilities that always travel together. If every caller of save also calls load, then a Repository with both is one role, and splitting it into Saver and Loader means every call site now takes two parameters instead of one, forever, for no benefit.
Do not create an interface per method by reflex. A codebase of forty one-method interfaces is genuinely harder to navigate than one with eight well-chosen roles. The unit is a role — a coherent job someone needs done — not a method.
Do not split when there is one implementation and one caller. There is nothing to segregate yet. Wait until a second implementation cannot honour part of it, or a second caller needs only part of it. That is the evidence; before it arrives you are guessing at shapes (9.3.4).
The balance: a fat interface makes implementers lie. Too many tiny interfaces make callers assemble a jigsaw at every call site. Aim for interfaces that match the jobs your callers actually have.
6. Where TypeScript changes the calculation
Two language features make this principle cheaper to follow here than in Java or C#, and they are worth knowing because they change when you should act.
Structural typing means you can define a role afterwards. A type is satisfied by shape, not by declaration (3.7.2). So you can introduce Locatable today over classes written last year that never mentioned it, with no edits to them at all. In a nominal language every implementing class must be modified, which is why people there feel pressure to guess the split early.
You can narrow at the call site without touching the interface at all:
typescript
function trackOnMap(units: Pick<Courier, "currentLocation">[]): void { /* … */ }Pick builds a type containing only the named methods. This gives you the caller-side benefit — honest, minimal coupling — without declaring a new interface, which makes it a good first step when you are not yet sure the role deserves a name.
Both facts point the same way: you can wait for evidence. Split when a real implementation cannot honour part of the interface, or a real caller only wants a slice. Not before.
7. Interview calibration
The forty-second answer: "No client should be forced to depend on methods it does not use. It has two victims — implementations end up stubbing methods they cannot honour, which turns into 'not supported' exceptions at runtime, and callers end up coupled to methods they never call, so unrelated changes drag them into every review. The way I find the seams is to look at each caller and write down which methods it actually uses; the clusters are the real interfaces. The signal that I need to split is any implementation throwing 'not supported', and the signal that I have split too far is call sites having to take three interfaces where one job is being done."
The follow-up that comes up often: "how does this relate to Liskov?" They are the same problem seen from two ends. A too-wide interface forces implementations to stub methods, and a stub that throws is exactly a Liskov violation. Fixing the interface width fixes the substitution problem at its source, which is why an implementation throwing "not supported" should make you look at the interface rather than at the implementation.
Recall
- No client should be forced to depend on methods it does not use. Two victims: implementations stub methods they cannot honour, and callers get coupled to methods they never call.
- A fat
Courierinterface forces a drone to fake a schedule, throw on cash, and throw on scanning. The empty schedule is the dangerous one — no crash, just a courier the roster quietly stops using. - Split by caller, not by object. Write down which methods each call site actually uses; the clusters are the real interfaces. Then a
Schedulable[]parameter makes passing a drone a compile error rather than a runtime check. - Three costs of a fat interface: implementations lie, callers recompile for changes they do not care about, and fakes become expensive. An interface that is hard to fake is too wide.
- Signals to split: any "not supported" exception; an interface named after a thing rather than an ability; different implementations stubbing different halves.
- Do not split abilities that always travel together, do not make an interface per method, and do not split with one implementation and one caller — the unit is a role, not a method.
- In TypeScript you can wait, because structural typing lets you add a role over existing classes with no edits, and
Pick<T, "method">narrows at the call site without declaring anything.
Self-test: Name both victims of a fat interface and say which one people usually forget. Why is the empty availableSlots more dangerous than the method that throws? What is the one-line method for finding the seams? How does this principle connect to Liskov? When is splitting the wrong move?
Quiz Bank
InterviewWhat is Interface Segregation, and how does it relate to Liskov Substitution?
The statement: no client should be forced to depend on methods it does not use. There are two victims, and the second one is usually forgotten. Implementations are forced to supply methods they cannot honour, which becomes stubs that throw or silently do nothing. Callers are forced to depend on methods they never call, so a change to any part of a wide interface recompiles, retests and re-reviews modules that had no interest in it.
A concrete failure. One Courier interface with deliver, locate, schedule, take cash and scan. A drone can do the first two. Its implementation returns an empty schedule, which the roster reads as "fully booked" so drones quietly stop being assigned, and throws on the other two, so any code holding a Courier works for bikes and crashes for drones.
The connection to Liskov, which is the interesting half of the question. They are the same problem seen from two ends. A too-wide interface forces implementations to stub methods they cannot honour, and a stub that throws "not supported" is a Liskov violation — code written against the interface stops working when handed that particular subtype. So when you see a "not supported" exception, the instinct should be to look at the interface rather than at the implementation, because the implementation is doing the only thing it could. Fixing the width fixes the substitution problem at its source.
Finding the seams: split by caller, not by object. Go to each place the interface is used and write down which methods it actually calls. The clusters are the real interfaces. Once split, the compiler does work it could not do before — a function taking Schedulable[] makes passing a drone impossible to express rather than something to catch at runtime.
The limit to state: do not split abilities that always travel together, and do not create one interface per method. The unit is a role — a coherent job a caller needs done. Too many tiny interfaces means every call site assembles a jigsaw, which is its own kind of expensive.