Frontend Dependency Injection for TC39 Standards
Yadong Xie · August 3, 2022
Dependency injection has a reputation in frontend circles as an Angular thing that everyone else can skip. I think it's more useful than that, and the easiest way to show why is to watch a small React app fall apart under two ordinary requirements, then rebuild it. The code for every step is in react-di-demo: VersionOne, VersionTwo and Refactor, in that order.
The second half of the post is about a less visible problem. The usual way of doing DI in TypeScript relies on compiler features that hurt bundle size, block tree-shaking and are about to be superseded by the TC39 decorators standard. There is a way out at build time.
An app with two buttons
The app has two features: submitting an order, which asks for confirmation, and logging in.
Login looks the same: LoginService creates an AuthService and reports whether you're in. Every class makes the things it needs with new. For a first version, that's fine.
Requirement one: a dialog per platform
Then the order dialog has to follow each platform's design: Apple's on iOS, Material on Android.
The quickest change puts the choice inside OrderService:
It works, and it's wrong in a way that compounds. OrderService is about orders, yet it now knows every dialog implementation and every platform. A third platform means editing order code. A test for ordering has to deal with dialogs. The high-level policy depends on low-level details.
Requirement two: two ways to log in
Next, login gains WeChat alongside SMS.
This time we do better. AuthService becomes an abstract class with one implementation per method, and LoginService takes whichever one it's given:
LoginService is clean now. The cost moved into the components, which have to assemble the chain themselves:
Two components that differ in one line, each responsible for knowing how to build a LoginService and everything under it. Give AuthService a dependency of its own and every component that logs in has to change.
Two principles, one pattern
The two requirements exposed two different problems, and each has a name.
The first is about what a module depends on. The dependency-inversion principle says high-level modules shouldn't depend on low-level ones; both should depend on abstractions, and implementations should depend on those abstractions too.
OrderService should depend on a DialogService that describes the need, "ask the user to confirm", and the platform dialogs should implement it. Then OrderService never changes when a platform is added.
The second problem is about who creates dependencies. Inversion of control says a module shouldn't construct what it needs; it should receive it from something whose job is construction.
Dependency injection is the pattern that does both: modules declare their dependencies, usually as constructor parameters, and a container builds them and hands them over. Inversion of control is built in, since the container does the constructing. Dependency inversion comes along when what you register in the container are abstractions mapped to implementations.
A DI container for React in 30 lines
Angular has dependency injection built in. React has nothing comparable, but Angular's injector is available as a standalone library, injection-js (InversifyJS and redi are alternatives). It provides the container; React needs two small adapters.
The first is a component that creates an injector for its subtree. If there's already an injector above it, the new one becomes its child, so lookups fall through to the parent:
The second is a hook that asks the nearest injector for something:
The services get an @Injectable() decorator and declare what they need in their constructors. The dialog gets the same treatment AuthService got earlier:
The abstractions are abstract classes rather than TypeScript interfaces on purpose. An interface disappears at compile time, leaving nothing to use as a key in the container. An abstract class is still a value at runtime, so it works as one.
Now the components only ask for what they use:
and the decisions about which implementation goes where move to one place, the component tree:
The two Login components are identical, yet one logs in by SMS and the other by WeChat, because each resolves LoginService from the nearest container above it. The inner container registers its own LoginService as well as its own AuthService; otherwise the lookup would fall through to the parent's LoginService, which was built with SMS. Switching every dialog to Android is one line in the outer container. No order or login code changes for either requirement.
I later published the container and hook as use-inject.
The hidden cost: runtime reflection
One question has been skipped. OrderService's constructor says it needs a DialogService, but TypeScript types vanish at runtime. How does the injector know?
The usual answer takes three pieces. @Injectable() marks the class. The compiler option emitDecoratorMetadata makes the compiler emit each decorated class's constructor parameter types as design:paramtypes metadata. And a Reflect.metadata polyfill such as reflect-metadata stores that metadata so the injector can read it back. You can see the emitted calls in the TypeScript playground or in this injection-js prototype.
The demo shows what that costs in practice. Create React App compiles TypeScript with Babel, which emits no decorator metadata, so the demo had to eject from Create React App to add babel-plugin-transform-typescript-metadata and legacy decorator support, and to load a Reflect polyfill, before the injector worked at all. Beyond setup, there are three lasting problems:
It ships a polyfill. The Reflect metadata API isn't part of JavaScript, so every user downloads an implementation of it.
It defeats tree-shaking. Every decorated class becomes a call that writes into a global metadata registry when the module loads. A bundler can't prove that call is free of side effects, so it keeps every service, used or not.
It isn't the standard. emitDecoratorMetadata belongs to TypeScript's experimental decorators. The TC39 decorators proposal works differently: it has no parameter decorators and emits no type metadata, and once TypeScript adopts it the experimental version becomes legacy. Not every compiler supports the old behavior either; esbuild, for one, doesn't emit decorator metadata.
Moving the metadata to build time
The information the injector needs is all in the source code: the constructor's parameter types are right there. So instead of emitting runtime calls that record them, a compiler step can write them into the class as plain data.
injection-js already accepts this form: a class can list its dependencies in a static parameters field, and the injector reads it without any reflection (demo). Nobody wants to keep those in sync by hand, so a TypeScript transformer generates them, the same approach Angular's compiler takes in ngtsc. AST Explorer is the easiest way to work out the tree you need to produce.
injection-js-transformer turns this:
into this:
The decorators are gone, including parameter decorators like @Optional() that the standard won't have, and the dependency on DependencyService is now an ordinary reference in a static field. It plugs into webpack through ts-loader:
What's left is ordinary JavaScript. There's no polyfill to ship. Classes are plain declarations after the build, so unused services can be tree-shaken. And nothing depends on emitDecoratorMetadata or on experimental decorator semantics, so the code is ready for the standard.
The point
Dependency injection isn't an Angular feature. It's what you arrive at when you take two ordinary requirements seriously: depend on abstractions, and don't build your own dependencies. In React it takes a context, a hook and an existing injector. Done at build time instead of through runtime reflection, it stays small, tree-shakeable, and compatible with where JavaScript decorators are heading.