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.


Tech mentioned