Interface Segregation in TypeScript

Greg Pabian
4 min readApr 11, 2020

--

Even though some design patterns have originated in different, more object-oriented languages, we can still use them in TypeScript. Some adjustments (sometimes even mental adjustments) might be required nevertheless. One of such patterns is the Interface Segregation Principle which is the I in the five SOLID rules.

Interface Segregation

Some software developers might have already realised, one way or the other, that a function (or method) should have all of its arguments of the most minimal types which are required so that the function can still behave in the same way. The real world reasoning here would be the following one — if we need to perform some work on the engine of a car, should we require the whole car, or just its engine?

Ensuring that functions you write follow the aforementioned rule means that your code adheres to the Interface Segregation Principle. The most obvious benefit is easier testability of a codebase that was more carefully planned than usual because of the limits the aforewritten principle imposes on the design process.

Single method example

Let’s say we want to print out to the standard output all the values of every iterable collection we might have in our system (for the purpose of this example, let’s say arrays, sets and maps). How can we achieve this in the easiest way possible?

const printOutCollection = <A, B>(collection: Array<A> | Set<A> | Map<A, B>) => {
collection.forEach(console.log);
}

In the printOutCollection function we observed that the declared three types share a common method called forEach on each of their respective prototypes which, in turn, can be leveraged for our needs.

There are two problems though:

  • we specified three mutable types which we can and should reduce to the immutable counterparts (ReadonlyArray, ReadonlySet and ReadonlyMap) and the function will still work the same as we don’t mutate the input structure (this fact doesn’t mean that our function is pure as it has a side-effect of interacting with the console),
  • if we were to introduce another type (e.g. Queue<T>), we would need to add such a type to that function, which directly violates the O in the SOLID rules, the so-called open-closed principle.

We can solve this predicament by introducing a generic interface:

interface ForEachAble<T> {
forEach(callback: (value: T) => void): void;
}

In TypeScript, types are based not on the inheritance chain (nominal typing), but on the property matching (duck typing / subtyping), therefore ReadonlyArray, ReadonlySet and ReadonlyMap and other collections implicitly implement the ForEachAble<T> interface. The aforementioned collections implement the STL’s Iterable<T> interface as well. In case of the ReadonlyMap<A,B>, interestingly enough, the T generic type is substituted by the [A, B] tuple (the 2-tuple, to be exact).

The ForEachAble<T> interface doesn’t require anything from its implementation other than having a method called forEach with at least callback: (value: T) => void) parameter and the void return type. Even though the interface should be used with collection (based on its name), it may be even used with a simple object like:

const nonCollectionForEachAble: ForEachAble<number> = {
forEach: (callback: (value: number) => void): void => {
return callback(0);
},
};

Despite the fact that I cannot think of any reason to use this construct in any production code, it could be used as a structure for testing whether the forEach function has been called or not by a function that operates on an instance of the ForEachAble<T> interface.

Now, we can declare a function:

const printOutForEachAble = <T>(forEachAble: ForEachAble<T>) =>
forEachAble.forEach(console.log);

The aforedeclared function works with:

  • ReadonlyArray<T> and Array<T>,
  • ReadonlySet<T> and Set<T>,
  • ReadonlyMap<K, V> and Map<K, V>,
  • and every other structure that implements the ForEachAble<T> interface directly or indirectly (including any third-party code).

Object splitting example

There is a similar way of reducing complexity in function arguments by splitting passed objects into independent pieces during the code design phase. Let’s define a type called ControllerDependencies that would hold all the necessary dependencies required for creating a REST controller (it is complex on purpose and for the purpose of the following example).

type ControllerDependencies = Readonly<{
database: {
verifyConnection: () => Promise<boolean>;
// repositorial methods or instances
};
getCurrentTime: () => Date;
// other data
}>;

We could use any instance of that type to easily create multiple controlers in a following fashion:

const buildEntryController = (deps: ControllerDependencies) => {
...
}

There are multiple issues with this approach:

  • the ControllerDependencies type is responsible for passing too many dependencies (which violates the Single Responsibility Principle as it joins too many different concepts into one entity),
  • creating an instance of the ControllerDependencies type in a test environment might be a challenge,
  • it is very likely that most of the controllers do not require all the dependencies, they just require a certain subset of them,
  • it’s hard to say where an instance of the ControllerDependencies type has data and functions that are impure (I am writing here not only about possible side effects, but also e.g. about cacheable functions).

One of the possible refactoring is to get rid of the ControllerDependencies type and create multiple controller building functions with customised arguments, for instance:

const buildEntryController = (
verifyConnection: () => Promise<boolean>,
getCurrentTime: () => Date
) => { ... }

This way, it’s easy to test the controller as we need to provide only two functions for it to be created. Furthermore, the code inside the controller builder and the controller itself doesn’t have any access to data and functions that shouldn’t be required by it by design.

Summary

As shown, using Interface Segregation in TypeScript is different than most of the object-oriented languages because of duck typing — we can create interfaces that are indirectly implemented by the already existing types which allows us to build our own abstractions.

We should always strive not to pass large structures to functions as we cannot track how such structures are used by the functions. It is also significantly harder to test them compared to scenarios in which we use only primitive data types and simple functions.

It is reasonable not to always follow the Interface Segregation principle to the fullest extent as creating too many interfaces might result in a very bloated codebase.

--

--

Greg Pabian
Greg Pabian

Written by Greg Pabian

A Full-stack Software Engineer that loves building products. Disclaimer: https://gist.github.com/grzpab/3cf57878ffce7d5271298ccc473bcb98

No responses yet