The last letter in SOLID is a Dependencies Inversion Principle. It helps us decouple software modules so that it is easier to replace one module with another. The dependency injection pattern allows us to follow this principle.
In this post, you will learn what dependency injection is, why it is useful, when to use it, and what instruments and tools can help frontend developers use this pattern.
What you will learn#
At the end of this post, you will learn what dependency injection is useful for, how to set it up in your TypeScript application, in what cases you're going to need it, and which tools can make it easier to do.
What a dependency is#
In general, the concept of a dependency depends on a context, but for simplicity, we will call a dependency any module which is used by our module. When we start using a module in our code, this module becomes a dependency.
Analogy with functions#
Without going into academic definitions, we can compare dependencies with function arguments. Both are used in some way, both affect the functionality and operability of software that depends on them.
In the example above the
random function takes 2 arguments:
max. If we don't pass one of them, the function will throw an error. We can conclude that this function depends on those arguments.
However, this function depends not only on those 2 arguments but on the
Math.random function as well. This is because if
Math.random isn't defined, the
random function also won't work—so
Math.random is a dependency too.
We can make it clearer if we pass it as an argument into our function:
Now it is clear that
random function uses not only
max but some random numbers generator as well. This kind of function will be called like that:
...Or if we don't want to manually pass
Math as the last argument every time, we can use it as a default value in function arguments declaration:
This is primitive dependency injection#
Of course, it is not yet “canonical”, it is very primitive and it has to be done by hand, but the key idea is the same: we pass to our module everything it needs to be working.
Why you need dependency injection#
The code changes in the
random function example may seem unnecessary. Indeed, why would we extract
Math into an argument and use it like that? Why wouldn't we just use it as it were, in the function body? There are 2 reasons for that.
When a module explicitly declares all the things it's going to need, this module is much simpler to test. We see what needs to be prepared to run the test right away. We know what affects this module's functionality and, if needed, can replace it with another implementation, maybe even fake implementation.
Objects that look like a dependency but do something different are called mock objects. When running tests, they might keep track of how many times some function was called, how a module's state changed, so that later we could check the results with expected.
They, in general, make it simpler to test a module, and sometimes they are the only way to test a module. This is the case with our
random function—we cannot check the final result that this function should return since it is different every time this function is called. However, we can check how this function used its dependencies and derive the results from that.
2. Ability to change a dependency#
Replacing a dependency while testing is only a special case. In general, we may want to replace a module with another for any reason. And if a new module behaves the same way as the previous, we can do that without any problems:
It is very convenient when we want to keep our modules as separate from each other as we can.
However, is there a way to guarantee that a new module contains the
random method? (It is crucial since we rely on this method later in function
random.) Apparently yes, there is. We can do it with interfaces.
An interface is a functionality contract. It constraints of a module behavior, what it must do, and what it must not. In our case, to guarantee
random method existence we can use an interface.
Defining the behavior#
To fixate that a module should have a
random method that returns a number, we define an interface:
To fixate that a concrete object must have this method, we declare that this object implements this interface:
Now we can declare that our
random function takes as the last argument only a object that implements the
If we now try to pass an object which doesn't implement the
RandomSource interface, TypeScript compiler will throw an error.
Depending on an abstraction#
At a first glance, this might seem like overkill. However, this helps us achieve many perks.
We drastically decrease modules coupling this way.
We have to design our systems before we start coding.
When we design a system beforehand we tend to use abstract contracts. Using those contracts we design our own modules and adapters for 3-party code. This unlocks the ability to interchanges modules with others without changing the whole system but only a changing part.
Especially it becomes handy when modules are more complex than those in the examples above. For instance, when a module has an internal state.
In TypeScript, there are many ways to create a stateful object, such as using closures or classes. In this post, we will use classes.
As an example, we will take a counter. As a class, it would be written something like this:
Its methods give us a way to change its internal state:
It's getting though when some objects like this one depend on others. Let's assume that this counter should not only keep and change its internal state but also log it into a console every time it changes.
There we see the same problem as we saw at the beginning of this post. Counter uses not only its state but also another module—
console. Ideally, it should also be explicit, or in other words, injected.
Dependency injection in classes#
We can inject a dependency in a class using a setter or a constructor. We will latter.
A constructor is a special method that gets called when an object is being created. You would usually specify all the actions to perform at the object initialization.
For example, if we want to log a greeting into a console when an object is created we can use this code:
Using a constructor we can also inject all the required dependencies.
We want to “teach” a class to work with dependencies the same way as functions from examples before.
So, our class
Counter uses the method
log of a
console object. That means that this class expects as a dependency an object that has a method
log. It doesn't matter whether it will be a
console object or another, the only condition here is for the object to have a
When we want to fixate the behavior we use interfaces, so the
Counter's constructor should take as an argument an object that implements an interface with method
To initialize a class instance we would use this code:
...And if we would want to, let's say, alert instead of logging into a console, we would change the dependency object this way:
Automatic injection and DI-containers#
Right now our
Counter class doesn't use any implicit dependencies. That's good, however, this injection is not convenient.
We have to manually inject every dependency,
We have to keep the dependencies order when injecting.
In reality, we would want to automate it. There is a way to do that, and it's called a DI-container.
On the whole, a DI-container is a module that does only one thing—it provides dependencies to every other module in a system. Container knows exactly which dependencies a module needs, and injects them when needed. Thus we free other modules of figuring out this stuff, and the control goes to a special place. This is the behavior that is described in SRP and DIP principles of SOLID.
A container knows what kind of behavior a module needs, knows which modules implement it, and when creating an object it will provide access to them automatically.
In pseudocode it would look like this:
Despite the fact that this code is not real, it is not so far from reality.
Automatic injection tools#
There is a great tool for TypeScript, that does exactly the thing we described above. It uses generic-functions to bind an interface and implementation. The code uses this tool would look like this: