Apologies in advance for the long read, and it feels fair to state outright that the purpose of this article is to promote a new OSS project, get community feedback and hunt for possible future contributors / maintainers. You have been warned ;-)
I have a long-standing beef with the state of dependency injection frameworks and libraries in Typescript. My primary background is in PHP; being used to how things are done in popular PHP frameworks, I have certain expectations on what dependency injection means. In particular, there is a principle - in my opinion, one of the core principles of doing DI correctly - which is consistently ignored by all existing DI frameworks in Typescript (with one notable exception, which we'll cover later):
Application code shouldn't know that dependency injection exists.
To elaborate: aside from declaring its dependencies, application code shouldn't care where and how these dependencies are obtained. It shouldn't assume that a particular DI implementation will be used, or even that any kind of DI implementation will be used - indeed, it should be possible to provide the dependencies even manually, if needed.
Yet all current implementations of dependency injection in Typescript that I've examined depend on either @decorators
or literal hard-coded service names.
What this means in practice is that application code is directly tied to a single particular DI library. This literally adds a static hard-coded dependency to every single piece of application code which takes part in the application dependency graph.
What's wrong with that?
Take the following example from the introductory text of InversifyJS, perhaps the most popular Typescript DI solution (at least according to NPM download stats):
import { injectable, inject } from "inversify";
// ...
@injectable()
class Ninja implements Warrior {
private _katana: Weapon;
private _shuriken: ThrowableWeapon;
public constructor(
@inject(TYPES.Weapon) katana: Weapon,
@inject(TYPES.ThrowableWeapon) shuriken: ThrowableWeapon
) {
this._katana = katana;
this._shuriken = shuriken;
}
// ...
First, we're using the @injectable
decorator to inform the DI container that the Ninja
class should be available as a dependency for other services, and then we're applying the @inject
decorator to the constructor arguments to ask the DI container to inject specific dependencies when instantiating Ninja
. A couple of observations:
-
Ninja
now knows that there is a DI container and that it will be used as a service within that container, due to the@injectable
decorator, even though it has no business knowing that. - The runtime service identifiers we're passing to
@inject
(TYPES.*
) have no compile-time binding to the actual service types we want to inject - meaning that although internally our service is type-safe, the dependency injection itself is not, because the type checker has no way of knowing that the@inject(TYPES.Weapon)
decorator will actually result in the library injecting aWeapon
instance. When we are registering services, there is likewise no type safety for what we bind to a particularTYPES.x
symbol. - Depending on the specifics of the DI library,
Ninja
now may not be able to exist without the DI container. We can't extract it into a NPM package to share between projects (at least not without refactoring). We may not be able to instantiate it manually in tests. It is now intrinsically tied to a single particular DI library. - If we leave out the
@inject
decorators, the remaining code will still satisfy the main prerequisites of dependency injection:Ninja
still explicitly states that it depends on an instance ofWeapon
andThrowableWeapon
, both of which are abstractions. I know, Typescript types aren't available at runtime - but the fact remains that the code we've written already includes all the important information, even without the decorators.
Another example for InversifyJS: if we declare multiple services of the same type and we want to inject all of them as a dependency, we can do it like this (simplified from InversifyJS docs):
class Ninja {
public constructor(
@multiInject("Weapon") public weapons: Weapon[]
) {}
}
Now, on top of all the previous complaints listed above, we're committing another crime against dependency injection: we're explicitly telling the DI container how it should satisfy our dependency - we're explicitly using a different decorator to tell the container to give us a list of services, rather than a single instance.
Similarly, when we have multiple services of the same type and we only want to inject one of them, we need to use an additional @named
decorator within the constructor parameters of the depending class.
One other aspect of decorator-based DI libraries is that they universally leverage the legacy experimental decorators feature of Typescript, which requires a compiler flag or two and possibly a runtime polyfill - but this version of the decorator specification is obsolete as of Typescript 5.0, and as far as I know, none of the existing DI libraries have yet migrated to the new ECMAScript decorators.
Async service initialization
A less obvious situation where decorator-based DI implementations generally don't play very nice is when a service requires asynchronous initialization. This is typical of e.g. database connections. And Promises are one of the things that decorators do not, cannot ever, support. In these cases, as far as I'm aware, we're reduced to either injecting Promises (or callbacks returning Promises), or to eagerly initializing async services during application startup, before we can access any service that depends on them, both directly and indirectly through other services.
If we decide to inject Promises for asynchronously-initialized services, then one of two scenarios unfolds: either the depending service keeps a reference to the Promise and then needs to resolve it every time it needs to access the dependency; or we write an async factory for the depending service, which will first resolve the Promise for the async dependency and then create an instance of the depending service with the resolved instance of the async dependency - but in that case the depending service also becomes asynchronous, because its factory is asynchronous - and then every service which depends on it will need to also deal with this problem. Thus asynchronous initialization propagates down the dependency tree.
Okay, we get it, decorators bad. What else is there tho?
The two exceptions to decorator-based DI that I found are Awilix and @wessberg/di
.
Awilix uses hard-coded service identifiers to do its thing: you register services using e.g. container.register({ ninja: awilix.asClass(Ninja) })
and then inject them using e.g. constructor({ ninja }) { ... }
(notice the object destructuring). In this case ninja
is the service identifier. This, at least, (mostly) limits explicit references to the DI library to a single file with a lot of container.register()
calls. But I still see a number of issues:
- No apparent type safety - you can of course explicitly type
constructor({ ninja }: { ninja: Ninja })
, but as before, this only gives you internal type safety - it doesn't type-check the injection itself. - Hard-wired literal service identifiers get tedious fast and they are hard to refactor, unless you're a
RegexpNinja
. - You either have to inject dependencies using an object, which again breaks my pet "code-shalt-not-know-of-thy-DI" principle, or you can use the "classic" injection mode where you specify individual dependencies as individual constructor arguments, but this can break if you transpile and / or minify your code.
- Async services are somewhat supported if you use Awilix Manager, but it's extra work and async services seem to need explicit eager initialization; I'm super lazy and I like my code even lazier.
The next alternative to decorator-based DI is @wessberg/di
, which finally seems to be a step in the right direction: this library hooks into the Typescript compiler API to do its thing at compile time, rather than at runtime. This is the "notable exception" mentioned earlier. Essentially, it comes in two parts: a Typescript transformer plugin, which extracts information about services' and dependencies' types while Typescript is compiling your code, and a small runtime container implementation. Crucially, the services themselves do not need any extra code apart from argument types, which you'd provide anyway.
"Wow, this looks really neat!" I thought at first. And it is, really - it comes the closest by far to what I'd consider a clean DI implementation. Unfortunately, it still has a couple of shortfalls:
- You need to register your services manually, one by one. That can be a lot of code.
- Typescript Transformer plugins only work with single files during compilation, rather than the whole project; and the
@wessberg/di-compiler
plugin generates runtime service identifiers from local type names, so possibly there may be issues with conflicting / mismatching service identifiers when using import / export / type aliases (though I haven't verified this). - And most importantly, still no proper, native support for async services - and this is a pretty big show-stopper for me.
But, coming across this project finally pushed me to attempt what I've been dreaming about for a long, long time...
Behold:
DICC
the Dependency Injection Container Compiler
...
...
... no, not a typo, I really named it DICC. Call it a self-insert. After all, I am the dic* who is telling multiple entire communities that they're all doing it wrong and only I am an airplane.
Anyway.. yeah, DICC. It goes a step further than @wessberg/di
in that it requires a separate build step - but thanks to that, it can take into account context which wouldn't be available when considering each file separately. Similarly to @wessberg/di
, it analyses Typescript types to figure out service dependencies, but it can do a number of things that @wessberg/di
can't:
- It takes into account inheritance - e.g. if you have
abstract class A implements AnInterface {}
andclass B extends A {}
, thenB
will be injected whenever a service depends onA
orAnInterface
. - In most cases it doesn't require explicit service registrations - it is enough that a service class is exported from one of the files DICC is pointed at during container compilation.
- It natively supports async services, without the need for eager initialization or manual registration. It compiles a factory function for each service and automatically takes care of awaiting async dependencies. This does result in the depending services becoming async as well, triggering the aforementioned "async init propagation", but this mostly happens under the hood and you usually don't need to know or care. Importantly, the services themselves don't need to know whether one or more of their dependencies needs async initialization.
- The generated container definition is regular Typescript code that you can commit into VCS - and crucially the generated code is strictly typed, so the injection itself is type-safe.
There are a lot of other features, like auto-generated service factories from interface declarations, service decorators, or smart container merging - you can read about them in the docs.
DICC is currently in a "working prototype" phase: there is definitely room for improvement, but the core already does its job. It can safely be used in production. The three main areas for future development, in my opinion, are:
- Compilation speed. The DICC compiler is pretty slow. This is somewhat mitigated by the fact that you only need to rebuild your container when your service definitions change (that is, when you add / remove services or when a service's constructor signature changes).
- Extensibility. Currently, there is no way to hook into the DICC compiler with custom compile-time extensions.
- Internal architecture. This is something of a prerequisite for extensibility, but it would be an improvement in general to overhaul the internal architecture of the project.
So, what do you think? Is it finally time we get DI in TypeScript done right? Let me know in the comments! And if you like DICC enough that you'd consider using it on a project, please feel free to reach out - I'd be very happy to get feedback from people with usecases which differ from mine.
Top comments (0)