Yadong Xie / 谢亚东
ProjectsLabWritingSaved|GithubLinkedInX
Writing

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.

export class AppleDialogService {
  confirm(message: string): void {
    alert(`Apple Design Spec Message: ${message}`);
  }
}

export class OrderService {
  private dialogService = new AppleDialogService();
  confirmOrder(message: string): void {
    this.dialogService.confirm(message);
  }
}

export const Order: FC = () => {
  const orderService = new OrderService();
  return (
    <button onClick={() => orderService.confirmOrder("Submit Order")}>
      Submit Order
    </button>
  );
};

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:

export class OrderService {
  private appleDialogService = new AppleDialogService();
  private androidDialogService = new AndroidDialogService();
  private env = "android";
  confirmOrder(message: string): void {
    if (this.env === "apple") {
      this.appleDialogService.confirm(message);
    } else if (this.env === "android") {
      this.androidDialogService.confirm(message);
    }
  }
}

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:

export abstract class AuthService {
  abstract authenticated: boolean;
  abstract name: string;
}

export class SMSAuthService implements AuthService {
  name = "SMS";
  authenticated = true;
}

export class WeiXinAuthService implements AuthService {
  name = "WeiXin";
  authenticated = false;
}

export class LoginService {
  constructor(private authService: AuthService) {}
  login(): void { /* ... */ }
}

LoginService is clean now. The cost moved into the components, which have to assemble the chain themselves:

export const SMSLogin: FC = () => {
  const authService = new SMSAuthService();
  const loginService = new LoginService(authService);
  return <button onClick={() => loginService.login()}>Login</button>;
};

export const WeiXinLogin: FC = () => {
  const authService = new WeiXinAuthService();
  const loginService = new LoginService(authService);
  return <button onClick={() => loginService.login()}>Login</button>;
};

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:

export const InjectorContext = createContext<Injector>(Injector.NULL);

export const DIContainer: FC<{
  providers: Provider[];
  children: ReactNode;
}> = ({ children, providers }) => {
  const rootInjector = useContext(InjectorContext) as ReflectiveInjector;
  const contextInjector = useRef<Injector | null>(null);
  if (contextInjector.current === null) {
    if (rootInjector === Injector.NULL) {
      contextInjector.current = ReflectiveInjector.resolveAndCreate(providers);
    } else {
      contextInjector.current = rootInjector.resolveAndCreateChild(providers);
    }
  }
  return (
    <InjectorContext.Provider value={contextInjector.current}>
      {children}
    </InjectorContext.Provider>
  );
};

The second is a hook that asks the nearest injector for something:

export function useInject<T>(
  token: Type<T> | InjectionToken<T>,
  notFoundValue?: T,
): T {
  return useContext(InjectorContext).get(token, notFoundValue);
}

The services get an @Injectable() decorator and declare what they need in their constructors. The dialog gets the same treatment AuthService got earlier:

@Injectable()
export abstract class DialogService {
  abstract confirm(message: string): void;
}

@Injectable()
export class AppleDialogService implements DialogService {
  confirm(message: string): void {
    alert(`Apple Design Spec Message: ${message}`);
  }
}

@Injectable()
export class OrderService {
  constructor(private dialogService: DialogService) {}
  confirmOrder(message: string): void {
    this.dialogService.confirm(message);
  }
}

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:

export const Login: FC = () => {
  const loginService = useInject(LoginService);
  return <button onClick={() => loginService.login()}>Login</button>;
};

and the decisions about which implementation goes where move to one place, the component tree:

<DIContainer
  providers={[
    OrderService,
    LoginService,
    { provide: DialogService, useClass: AppleDialogService },
    { provide: AuthService, useClass: SMSAuthService },
  ]}
>
  <Login></Login>
  <DIContainer
    providers={[
      LoginService,
      { provide: AuthService, useClass: WeiXinAuthService },
    ]}
  >
    <Login></Login>
  </DIContainer>
  <Order></Order>
</DIContainer>

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:

import { Injectable, Optional, SkipSelf } from "injection-js";

@Injectable()
export class DependencyService {}

@Injectable()
export class DIService {
  constructor(
    @SkipSelf()
    @Optional()
    private dependencyService: DependencyService,
  ) {}
}

into this:

import { Injectable, Optional, SkipSelf, Inject } from "injection-js";

export class DependencyService {}

export class DIService {
  constructor(private dependencyService: DependencyService) {}
  static parameters = [
    [new Optional(), new SkipSelf(), new Inject(DependencyService)],
  ];
}

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:

import injectionTransformer from "injection-js-transformer";

// in the ts-loader options
getCustomTransformers: (program: Program) => ({
  before: [injectionTransformer(program)],
}),

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.

←From GUI to NUI: How LLMs Change the Way We Interact