The interface segregation principle — half the motivation depends on the language
ISP is the principle that a client should not be forced to depend on methods it does not use. The definition is easy to grasp and violations are easy to spot. What still leaves you undecided in practice is that the unit to split along does not follow from the definition.
On top of that, this principle contains a motivation that disappears depending on the language. Without separating the part born of C++'s circumstances from the part that lives on regardless of language, you cannot settle what to do in TypeScript.
What the original said — there are two motivations
Martin's 2000 document defines ISP this way1.
Many client specific interfaces are better than one general purpose interface
Note that it says "many are better." Read the definition alone and it looks as though the finer you slice, the better. As we will see, the original rejects that reading in the same section.
The explanation that follows shows the problem with a large interface using a diagram. About a situation where three clients depend on one fat service, it says:
Note that whenever a change is made to one of the methods that ClientA calls, ClientB and ClientC may be affected. It may be necessary to recompile and redeploy them.
Two motivations are contained here.
- The cost of recompiling and redeploying. In C++, a change to a header you depend on triggers a rebuild. On large 1990s projects this weighed heavily as actual waiting time
- The widening blast radius. ClientB does not use the methods meant for ClientA, yet it is affected by the change
Only the second remains in TypeScript. A type change can trigger a build, but not at the granularity of C++ header dependencies. When you say "we follow ISP because it is the industry standard," you need to check whether you are carrying over the motivation that has disappeared.
The unit to split along is the kind of client
The original also states the unit of splitting explicitly. This is the passage that bites most in practice.
The ISP does not recommend that every class that uses a service have its own special interface class that the service must inherit from. If that were the case, the service would depend upon each and every client in a bizarre and unhealthy way. Rather, clients should be categorized by their type, and interfaces for each type of client should be created.
It also states explicitly that duplication is allowed.
If two or more different client types need the same method, the method should be added to both of their interfaces. This is neither harmful nor confusing to the client.
The criterion for splitting is how the client uses it, not what suits the service. And if two kinds need the same method, duplicate it. This is the same idea as chapter 2's SRP. The criterion for grouping is not "is it the same code" but "is it the same party using it."
Why follow it — do not make anyone implement what they cannot
An ISP violation does real damage when the implementing side is forced to fill in methods it cannot provide.
Let us look at managing a library's copies.
interface CopyRegistry {
findById(copyId: string): string | undefined;
listByShelf(shelf: string): readonly string[];
register(copyId: string, shelf: string): void;
discard(copyId: string): void;
}
// The browsing terminal only searches, yet it is made to implement the update operations
class ReadOnlyCopyRegistry implements CopyRegistry {
findById(copyId: string): string | undefined {
return undefined;
}
listByShelf(shelf: string): readonly string[] {
return [];
}
register(copyId: string, shelf: string): void {
throw new Error("This registry is read-only");
}
discard(copyId: string): void {
throw new Error("This registry is read-only");
}
}
The moment implementations that throw line up, an ISP violation has occurred. And this is simultaneously an LSP violation. Code that received a CopyRegistry believes register works, and this implementation betrays that. In the previous chapter's terms, it strengthens the precondition.
Withhold the methods it cannot provide and there is no contract left to break. This link — that following ISP structurally prevents LSP violations — is not in the original's ISP section; it is this series' framing.
How it gets misused — slicing per method
The misuse runs in the direction of "the finer you slice, the better."
interface CopyFinder {
findById(copyId: string): string | undefined;
}
interface ShelfLister {
listByShelf(shelf: string): readonly string[];
}
interface CopyRegistrar {
register(copyId: string, shelf: string): void;
}
interface CopyDiscarder {
discard(copyId: string): void;
}
class DatabaseCopyRegistry
implements CopyFinder, ShelfLister, CopyRegistrar, CopyDiscarder
{
// ...
}
Martin himself warns against this state.
As with all principles, care must be taken not to overdo it. The specter of a class with hundreds of different interfaces, some segregated by client and other segregated by version, would be frightening indeed.
The reason per-method splitting is wrong is that the criterion for splitting does not come from the client. The unit the original names was the kind of client. If there are only two kinds of client, the browsing terminal and desk operations, then there are two interfaces.
// Operations the browsing terminal uses
interface CopyLookup {
findById(copyId: string): string | undefined;
listByShelf(shelf: string): readonly string[];
}
// Operations desk operations use
interface CopyMaintenance {
findById(copyId: string): string | undefined; // Duplication is fine
register(copyId: string, shelf: string): void;
discard(copyId: string): void;
}
findById appears in both, and as the original states explicitly, that is not a problem. Each client having what it needs takes priority over avoiding duplication.
When it's OK to break it
Deciding not to apply ISP is sound in the following cases.
When there is only one kind of client. Split it and both interfaces are used by the same party. There is nothing to gain.
When every implementation can always provide every method. The real damage from ISP was being made to implement methods you cannot provide. If every implementation can provide every method correctly, that damage does not arise. Whether to split then comes down to readability.
When the classification of clients is not settled yet. To split per kind you need to know the kinds. Split before you know and you get the same failure as chapter 3's predictive abstraction.
Where TypeScript stands today — satisfying it without carving an interface
This is where TypeScript changes things most. In a structural type system you can satisfy ISP without declaring a named interface.
You just write the members you need directly into the function's parameter type.
function renderShelfView(
registry: { listByShelf(shelf: string): readonly string[] },
shelf: string,
): readonly string[] {
return registry.listByShelf(shelf);
}
This function requires only listByShelf. Without declaring either CopyLookup or CopyMaintenance, it expresses the minimal client-specific contract. Callers can pass any object that has that method.
You can also carve views out of an existing type.
type FullRegistry = {
findById(copyId: string): string | undefined;
listByShelf(shelf: string): readonly string[];
register(copyId: string, shelf: string): void;
discard(copyId: string): void;
};
type LookupView = Pick<FullRegistry, "findById" | "listByShelf">;
type MaintenanceView = Pick<FullRegistry, "findById" | "register" | "discard">;
Types built with Pick follow along automatically when the original type changes. There is no work keeping two hand-written interfaces in sync.
The difference from nominally typed languages
In nominally typed languages such as PHP or Java you cannot write it this way. Type identity is decided by name, so you have to declare the interface and write implements on the implementing class. Splitting becomes an act of declaration, and it costs accordingly.
In TypeScript, with no declaration required, the cost of satisfying ISP is close to zero. You only write the parameter type as narrowly as needed. Turned around, that means you need a separate reason to go to the trouble of declaring an interface.
| Purpose | Is a named interface needed |
|---|---|
| Minimizing what a client uses | No. Write it directly into the parameter type |
| Making the implementing side state "I satisfy this contract" | Yes. implements detects omissions |
| Naming a contract so it becomes vocabulary | Yes. Readers can then treat it as a concept |
| Reusing the same shape in several places | A type alias is often enough |
ISP in TypeScript is realized as "write the parameter type as narrowly as needed," not as "split the interface." Declaring an interface is something you choose for a different purpose.
How far structural subtyping makes implements unnecessary, and the conditions under which classes with private / protected are an exception, are covered in detail by TypeScript Design Patterns — Adapter.
Summary
- ISP has two motivations, and the cost of recompilation does not exist in TypeScript in its original form. What remains is the blast-radius problem
- The unit of splitting is the kind of client. The original explicitly rejects "a dedicated interface per client"
- If clients of different kinds need the same method, the original states explicitly that duplication is fine
- An ISP violation invites an LSP violation. Withhold the methods that cannot be provided and there is no contract left to break
- The misuse is splitting per method. Martin himself writes "care must be taken not to overdo it"
- In TypeScript you satisfy ISP just by writing the members you need into the parameter type. Declaring an interface is something you choose for another purpose
What to read next
The next chapter covers the dependency inversion principle. The idea built in this chapter, that the client side decides the shape it needs, leads into the discussion of the direction of dependencies.
Exercises
Is the following interface split per client or per method? When does that become a problem?
interface Openable {
open(): void;
}
interface Closable {
close(): void;
}
interface Lockable {
lock(): void;
}
interface Unlockable {
unlock(): void;
}
class LoanDesk implements Openable, Closable, Lockable, Unlockable {
open(): void {}
close(): void {}
lock(): void {}
unlock(): void {}
}
Sample answer
Per method. The basis for the split does not come from the client.
The clue for deciding is that the interface names are nearly identical to the operation names. Openable says nothing beyond "it has open." Had it been split per client, the name would express "who uses it for what" — units like "opening the library" or "managing locks."
It becomes a problem when a client uses several operations as a set.
Code responsible for opening the library normally calls unlock and then open. Closing calls close and then lock. Given that usage, the units to split along are these two.
interface DeskOpening {
unlock(): void;
open(): void;
}
interface DeskClosing {
close(): void;
lock(): void;
}
Left per method, the code that opens the library ends up receiving two interfaces. There are more parameters, and the information that "these two are used as a set" is expressed nowhere.
There are also cases where it is not a problem. If the four operations really are used independently, and clients that use only unlock and clients that use only open genuinely exist, then this split coincides with a per-client split. You decide by looking at how clients actually call it.
Note that in TypeScript you can skip declaring these and write them straight into the parameter type. Write function openDesk(desk: { unlock(): void; open(): void }): void and the fact that they are used as a set shows up in the shape of the parameter.