Easier interface segregation in TypeScript (even better than Go!)
Preamble: Introduction to interface segregation
(Skip this if you’re aware of what the Interface Segration Principle is.)
The Interface Segration Principle is the I in the SOLID mnemonic. It’s conceptually one of the simplest, but is often overlooked, which is a shame as it’s a highly beneficial principle to follow in software design.
The Interface Segration Principle is defined as “no client should be forced to depend on methods it does not use”. More simply, it suggests that a client should be able to depend on the smallest possible interface, i.e. exactly what it needs and nothing more.
It’s common to see interfaces like this:
interface AccountManager {
createAccount(accountDetails)
getAccount(accountId)
updateAccount(accountId, accountDetails)
deleteAccount(accountId)
}
Client code then depends on that interface, as in this example:
class RegistrationService {
constructor(private accountManager: AccountManager, private emailService: EmailService) {}
registerUser(registrationRequest) {
this.accountManager.createAccount(someFactory.makeAccountDetails(registrationRequest))
this.emailService.sendWelcomeEmailToUser(registrationRequest.emailAddress)
}
}
This seems fine, and it’s not a big problem in this imaginary codebase. It does have some drawbacks though:
Firstly, if we want to change the RegistrationService
to use some other
dependency to create the account, we’d have to implement all the methods of
AccountManager
on that dependency. We could change the dependency in
RegistrationService
too, but it can get tricky if you have to identify exactly
which methods it’s using and how.
This situation would be even harder to resolve if we have multiple
implementations of AccountManager
that get used in different situations, with
dependency injection choosing which implementation to give to
RegistrationService
. In this part of the codebase, we’re only using the
createAccount
method, but we’d have to implement all of the other methods for
each implementation, even if they’re just dummy methods. This won’t be easy to
reason about, and gets tiresome.
Those other methods exist on the AccountManager
interface because other client
code is using them. What if some of that other client code needs a new method on
its dependency on AccountManager
? Now all of the other clients get their
dependency changed whether they need it or not. It’s easy for this to snowball,
with key interfaces accumulating more and more methods to satisfy the varied
needs of their clients.
This can turn into a vicious cycle: the more methods an interface offers, the more attractive it is to new client code, and the more client code that depends on it, the greater the temptation to add new methods to it.
Finally, if we’re writing unit tests for the RegistrationService
with the
AccountManager
mocked out, we have to mock out the other methods even though
they’re not being used in this instance. The same problem with new methods being
added will also occur – we have to make sure all the mock implementations also
get that new method.
Considering all of that, the dependency that RegistrationService
has on
AccountManager
doesn’t seem ideal. Wouldn’t it be better for it to depend only
on the methods it actually uses, and nothing more?
interface AccountCreator {
createAccount(accountDetails)
}
class DatabaseAccountManager implements AccountCreator, AccountUpdater, AccountDeleter {}
class RegistrationService {
constructor(private accountCreator: AccountCreator, private emailService: EmailService) {}
registerUser(registrationRequest) {
this.accountCreator.createAccount(someFactory.makeAccountDetails(registrationRequest))
this.emailService.sendWelcomeEmailToUser(registrationRequest.emailAddress)
}
}
In the example above, we’ve pulled out a smaller AccountCreator
interface that
does one thing – creates accounts. The RegistrationService
only needs that,
so that’s the dependency it defines. The DatabaseAccountManager
implementation
happens to also offer other things, but that doesn’t matter – the
RegistrationService
doesn’t know about that. This makes the refactoring and
mocking problems described above go away.
The catch is that, realistically, this often doesn’t happen in living codebases. These situations aren’t as obvious as the contrived examples here, and it’s hard to change habits to try and segregate interfaces more.
The trick is define interfaces that clients need, not interfaces that match
the concrete implementations you’re writing. One client needs a Countable
,
another needs an Iterable
. If you’ve written a nice ArrayIterator
implementation, it’s tempting to make that the interface, but it should be the
other way round. One implementation can implement both Countable
and
Iterable
, but clients should depend on the specific one they need.
Line-priced interface example
As another example, consider a situation where we need to render out the basket
in an ecommerce system. We’ve got a list of BasketItem
instances, which look
like this:
class BasketItem {
price: Price
description: string
productId: string
addedAt: Date
}
A trivial rendering might work like this:
function renderBasketItems(basketItems: BasketItem[]): string {
return basketItems.map(
(item) => `${item.description}: ${item.price.format()}`
).join('\n')
}
Notice that we’re not using the productId
or addedAt
properties of the
BasketItem
here.
Later we have a new requirement to also display special discounts on the basket as if they were also items in the basket. The rendering code has got a bit more complicated, but we’d like the discount lines to be rendered in the same way.
It would be easy if we could just append the discounts to the basket items array
and have them rendered in the same way. There might be a temptation to have
DiscountLine
extend BasketItem
, but that would get messy quickly. They could
both implement the same interface, but we don’t need productId
or addedAt
on
DiscountLine
.
This is a good chance to segregate off an interface: LinePriced
or something
like that.
interface LinePriced {
price: Price
description: string
}
class BasketItem implements LinePriced {
price: Price
description: string
productId: string
addedAt: Date
}
class DiscountLine implements LinePriced {
price: Price
description: string
}
Now the same rendering logic can receive all the lines and render them, without
needing to know what exactly each one is. The key point is that LinePriced
is
an interface based on the needs of client code, not on categorising
implementation code. BasketItem
and DiscountLine
might also implement other
interfaces separately to support other client code in different places.
An improvement: implicit interface implementation (e.g. in Go)
The above would be easier to achieve if you could define the required interface
with the client code, and not elsewhere with the implementation. One famous
feature of Go is that the way it does
interfaces encourages this. In Go,
interfaces are implemented implicitly, i.e. you don’t have to say FooClass implements BarInterface
. If FooClass
has the required methods for
BarInterface
, then the compiler will allow it to be passed anywhere
BarInterface
is required.
This means you can define the exact interface you need with the client code:
type geometry interface {
area() float64
perim() float64
}
func measure(g geometry) {
fmt.Println(g.area())
fmt.Println(g.perim())
}
Now we can pass any struct that has those method signatures, even if it’s from some third-party library that’s out of our control. This encourages focusing on what the client code needs when defining interfaces, which in turn encourages interface segregation.
Even better implicit interfaces in TypeScript
TypeScript has the old-school FooClass implements BarInterface
style of
interfaces, so you might think it’s not as good as Go on this front. However,
you don’t actually need to have implements BarInterface
for the TypeScript
compiler to allow an implementation to passed where BarInterface
is required.
Simply implementing the methods and properties is enough, as in Go.
TypeScript can take it one step further, though:
class OrderRedirector {
redirectOrder(order: { accountId: AccountID, orderDestination: OrderDestination }) {
//
}
}
In this class (or it could be an interface itself), we’re taking an order
argument, with a literal interface as the type. This could have been defined
explicitly as an interface, e.g RedirectableOrder
, and we might well want to
extract it into that if the same interface is required in multiple places.
The key thing is that we define exactly what we need in the exact place we need it. The TypeScript compiler will be happy to let us pass any object or interface that has those properties.
It’s easy to refactor to this style; you just replace an explicit interface with an implicit literal one with only the methods you need. This can often make it easier to write unit tests for existing code – use a small literal interface, and only mock out that in the unit tests with a small object literal.
Implicit interfaces combined with literal interface types is quite a powerful combination, and I often find it useful when working on TypeScript projects.