DEV Community

Cover image for Introducing DI Unchained - A new way to use Angular DI
Eduard Tar for Adroit Group Kft

Posted on

Introducing DI Unchained - A new way to use Angular DI

Did it ever occur to you how we can only use Angular's black magic DI tool in a certain way? We have no choice but to abide by the all-mighty framework's iron-fisted rule. That means using Di only on the framework's own terms. And terms we have. DI can only be called upon where the framework allows it. And that's only in the constructor of a class that's decorated with one of the chosen five.

What now?

Let me explain. There are five class decorators that are favoured by the framework above everything else. I'm talking about the @NgModule(), @Component(), @Directive(), @Pipe() and of course @Injectable() decorators. And that's it. No other way to use it. No other way to inject it. These five are like the deputies of Angular itself to watch over and ensure the order of our applications. We're left with no choice but to obey to their rules if we wish to receive the framework's blessing of DI powers.

But there's hope for change

I encourage you to read on. Learn about the truth of things. Understand the status quo and then decide for yourself if you want to continue to live in the dark. Or if you want to join us and stand up against this tyranny. Break the Wheel and pave the way for future generations to come. For a better tomorrow. For a better Angular.

Intrigued are we? Good. Let's begin.

So you have already learnt about the history which I assume you knew anyway. I also assume you've learnt about the recent swift of the aforementioned status-quo. I'm talking about Angular recently heeding the call of it's most devoted supporters and giving in on relaxing the rules one must follow to receive the gift of DI.

No, you haven't?

The new status-quo

With the introduction of a new form of prayer called inject(). We've been at least let loose from the grasp of constructors. An even lower authority than the chosen five. Yet a lot more abundant and unwilling to let loose of their power. inject allows us to bypass constructors entirely and inject our dependencies directly into the body of our classes. But that's not all. We can also use it to inject dependencies into functions. And that's where the real magic happens.

But don't let this fool you. Nothing really changed. The Wheel still stands and is still in motion. Angular merely let the chosen five rearrange the pieces to create the illusion of change, of freedom while their rules still apply. There's still no freedom. There's still no choice. DI is still held hostage by the chosen five. And that's why we need to break the Wheel.

The truth

The truth is that we can use DI anywhere we want. We can use it in any function we want. We can use it in any class we want. We can use it in any file we want.

I sincerely believe that Angular's attempt to quench our thirst for some hope of freedom without really changing anything has backfired for it. Despite the intention of giving us something that's really nothing has a flaw. Which makes it really something. But I'm not sure that you could handle the truth if you'd seen it. We're talking about things merely less mysterious and convoluted than the powers of Angular's DI. I'm not sure you are ready for what I'm about to show you, but here we go.

But before we tackle this "loophole" let's get the "basics" out of the way first. So you may understand how to utilize this opportunity.

The small science of JavaScript

One most important things to understand is that JavaScript is a language that's built on top of the concept of prototypes. This is common knowledge nowadays yet this is the stepping stone for us to reach a deeper understanding and learn of the small science of JS, metaprogramming. That's no heresy. That's science.

Understanding the fundamental truth that prototypes lead us to allows us to peer into the very essence of JS itself. An even greater power than Angular and dare I say React as well.
So what's the essence?

In JS everything is an object. Even functions are. They're in fact 'callable' objects. All objects' have their properties. Even methods are properties. All properties are bound to their objects with other special objects called property descriptors. These determine what kind of property we have and what we can or can't do with it. Proxies and Reflection are real as opposed to what they taught you in school. And we're going to use them all to finally break the Wheel.

What's the loophole?

You see there's no single thing that magically solves everything. Although if we had to point to one single thing that would be the inject() function mentioned earlier. Even though we now have the means to break the Wheel. It's still going to be a hard and complex task to undertake, with many steps.

In order to free ourselves we must first free the Injector itself from Angular. Yep, we're going to hijack Angular's very own blessing from itself for our purposes.

Let's lose the Injector

When the inject() function has been introduced by Angular, and a new Injector type was introduced as well. These are the EnvironmentInjectors which can roughly be described as the new module injectors. Their introduction was necessary due to the standalone APIs.

So now when you create an application with version 14 or higher. Your root module or component will have an Environment Injector. Our first business of order is to help it get out of Angular's grasp.

There's a fairly old way to do this. Which would have worked with the predecessor of EnvironmentInjectors as well. Basically, we ask Angular to give us its root injector. In our case, we now ask for the Environment Injector. It's a big favour to ask but Angular usually suspects nothing and fulfils our request. However, being able to ask for the Injector implies that we're in Angular's domain. That won't do, will it? We have to trick Angular with a little trick that consists of a ClassDecorator and a Proxy.

function DI(): ClassDecorator {
  return (target: any) => {
    return new Proxy(target, {
      construct(target, argArray, newTarget) {
        const instance = Reflect.construct(target, argArray, newTarget);

        window.DI_UNCHAINED_INJECTOR_SYMBOL = inject(EnvironmentInjector);

        return instance;
      },
    });
  };
}
Enter fullscreen mode Exit fullscreen mode

Let's see what we have here:

  • A class decorator factory function will be applied either to our root module or root component class.
  • The decorator function returned by the factory returns a Proxy that wraps the decorated class and traps it's constructor call. This gives us access to its construction logic that runs when the new operator is used without having to actually modify the class itself.
  • We ask Angular for the EnvironmentInjector with the help of the aforementioned inject() function.
  • We store the result in a global symbol that we can access from anywhere in our application.
  • We use the Reflect API to mimic the original behaviour of the constructor function. So that no change would be visible from the outside.

With this setup, we've already managed to break the EnvironmentInjector free from Angular. But how are we going to use it?

Getting to work

We're going to use a similar technique to use the EnvironmentInjector in a comfortable way. First, let me introduce to you a less-known capability of our dear friend the EnvironmentInjector. There's a method called runInContext() that allows the callback function passed to it to run inside the context of that injector. This means that we can use the injector to resolve dependencies for us. This is very similar to how NgZone's runGuarded function work. So let's create a function that uses the hijacked EnvironmentInjector from the global scope and runs our code in the context of that Injector.

export function runInContext<T extends () => any>(fn: T): ReturnType<T> {
  const envInjector = window.DI_UNCHAINED_INJECTOR_SYMBOL;

  return envInjector.runInContext(fn);
}
Enter fullscreen mode Exit fullscreen mode

Now that we can easily call upon the Di through the hijacked Injector. Let's put together the remaining pieces. So we can actually use it.

We're going to create yet another TypeScript decorator. This time however we are going to wrap a class member with it.

function Unchained() {
  return (
    target: any,
    propKey?: PropertyKey,
    descriptor?: TypedPropertyDescriptor<any>
  ): any => {
    return bindDescriptor(target, propKey!, descriptor!);
  };
}
Enter fullscreen mode Exit fullscreen mode

And the bindDescriptor function is going to do the actual work.

export function bindDescriptor(
  target: object,
  member: PropertyKey,
  descriptor: TypedPropertyDescriptor<unknown> & PropertyDescriptor
) {
  const { get, set, value } = descriptor;
  let changed = 0;

  if (typeof get === "function") {
    descriptor["get"] = () => runInContext(() => get());
    changed++;
  }

  if (typeof set === "function") {
    descriptor["set"] = (arg) => runInContext(() => set(arg));
    changed++;
  }

  if (typeof value === "function") {
    descriptor["value"] = (...args: unknown[]) =>
      runInContext(() => value(args));
    changed++;
  }

  if (changed) {
    Object.defineProperty(target, member, descriptor);
  }
}
Enter fullscreen mode Exit fullscreen mode

Again let's see what we do here:

  • We create a decorator factory function that will be applied to a class member.
  • The decorator returns the result of the bindDescriptor function. This means that the result of the function will replace the original class member definition.
  • We unpack the descriptor object and check if it has a getter, setter or value.
  • Getters and setters are fairly easy to handle. We just wrap them in a function that calls the runInContext function.
  • If we have a value that means we either have a method which is located on the prototype chain or a value which can either be a primitive or an object. We are only interested in the first case though. So we check if the value is a function and if it is we wrap it in a function that calls the runInContext function.
  • Then lastly we check if we actually changed anything. If we did we replace the original descriptor with the new one. This is where we actually overwrite the existing behaviour.

We can now apply this decorator to any class property or method anywhere and it will be executed in the context of the hijacked EnvironmentInjector. meaning we'll have access to the powers of DI.

But wouldn't it be easier if we didn't have to apply this decorator to each and every class member? Well, yes it would. So let's create a class decorator that will do that for us. In fact, let's just change our existing decorator to do that.

function Unchained() {
  return (
    target: any,
    propKey?: PropertyKey,
    descriptor?: TypedPropertyDescriptor<any>
  ): any => {
    const mode: "class" | "method" =
      !!propKey && typeof descriptor === "object" ? "method" : "class";

    return mode === "method"
      ? bindDescriptor(target, propKey!, descriptor!)
      : wrapClass(target);
  };
}
Enter fullscreen mode Exit fullscreen mode

The changes we've made:

  • We check where the decorator is being applied. This is the 'mode' variable.
  • Class and class member decorators receive different parameters from the __decorate() helper function from tslib. So using this knowledge we can check for the presence and shape of the extra parameters that only class member decorators receive.
  • Based on the inferred decoration 'mode' we either call the bindDescriptor function as we did before, for class members, or the wrapClass function for the whole class.

Let's see how wraClass() looks like.

export function wrapClass(target: Function) {
  const proto = target.prototype;
  if (!target || !proto) return;

  bindCLass(proto);
  bindCLass(target);

  return new Proxy(target, {
    construct: (target, argArray, newTarget) => {
      return runInContext(() => Reflect.construct(target, argArray, newTarget));
    },
  });
}
Enter fullscreen mode Exit fullscreen mode

We'll get to what bindClass() does in a minute. But first, let's see what we do here:

We use the Proxy API again to wrap the decorated class's constructor just as we did before. This is because all the instance properties of a class that are defined and initialized in the class body are actually moved and executed inside its constructor. For instance:

class MyClass {
  prop = "value";
}
Enter fullscreen mode Exit fullscreen mode

Becomes this:

class MyClass {
  constructor() {
    this.prop = "value";
  }
}
Enter fullscreen mode Exit fullscreen mode

I know, I know. Shocking right? ๐Ÿ˜ฑ But the thing is you already knew about this. Or at the least, you've suspected that something like this would be going on in the background when you started using TypeScript.

Now let's see what bindClass() does.

export function bindCLass(protoOrCtor: object) {
  const protoMembers = Object.entries(
    Object.getOwnPropertyDescriptors(protoOrCtor)
  ).filter(
    ([member, descriptor]) =>
      (typeof descriptor.value === "function" && member !== "constructor") ||
      descriptor.get ||
      descriptor.set
  );

  protoMembers.forEach(([member, descriptor]) =>
    bindDescriptor(protoOrCtor, member, descriptor)
  );
}
Enter fullscreen mode Exit fullscreen mode
  • We get all the class members of the decorated class constructor or prototype object. Remember from earlier that we've called bindClass() twice. Once for the constructor and once for the prototype. This way we'll be able to bind both static and instance methods.
  • Filter does members to get the getters, setters and methods. We don't want to bind the constructor because it's already been bound. And we don1T want to bind non-function values that reside on the prototype chain.
  • For each descriptor object in our list, we call the bindDescriptor() function.

*## Reap what we sow
*

With all this work done we can finally reap the benefits of our hard work. Let's see how we can use this decorator in our application.

Let's define an ordinary service that we'll use in our application. This service does nothing but will serve as a custom service that we'll inject into our components.

@Injectable({
  providedIn: 'root',
})
export class TestService {
  public name = 'TestService';
}
Enter fullscreen mode Exit fullscreen mode

Not to the fun part! We define our @Unchained() class as follows:

@Unchained()
export class TestClass {
  router: Router;

  static routerFn = () => inject(Router);

  static getRouter() {
    return inject(Router);
  }

  routerFn = () => inject(Router);

  constructor() {
    this.router = inject(Router);
  }

  public getTitleService() {
    return inject(Title);
  }

  public get neta() {
    return inject(Meta);
  }

  public static get appRef() {
    return inject(ApplicationRef);
  }
}
Enter fullscreen mode Exit fullscreen mode

As you can see our test class doesn't really do much, but it injects a lot of services from Angular while only using the @Unchained() decorator.

@Unchained()
export class TestChildClass extends TestClass {
  featureName = inject(TestService).name;
}

class OtherTestClass {
  @Unchained()
  getTitleService() {
    return inject(Title);
  }

  @Unchained()get Meta() {
    return inject(Meta);
  }
}
Enter fullscreen mode Exit fullscreen mode

We define two addition classes just for good measures.

@Component({
  selector: 'app-test',
  template: '',
})
export class TestComponent implements OnInit {
  title = 'DI-unchained';

  myInstance!: TestClass;

  otherInstance!: OtherTestClass;

  childInstance!: TestChildClass;

  ngOnInit(): void {
    this.myInstance = new TestClass();
    this.otherInstance = new OtherTestClass();
    this.childInstance = new TestChildClass();
  }
}

@NgModule({
  declarations: [TestComponent],
})
@DIContextProvider()
export class TestModule {}
Enter fullscreen mode Exit fullscreen mode

And finally, we define a component that will use our test classes. And a module to register our component. We also use the DIContextProvider() decorator to register our component's injector as a DI context.

You may be wondering how is this beneficial to us if we instantiated our classes inside a component. But that's just for the sake of simplicity. In reality, you'd be defining and using these classes left and right without a care in the world about how code execution gets to them and how they'd find their Injectors. However ultimately most if not all code execution in our Angular applications would begin from an Angular component or module.

We are finally free. Yaay! Now what?

I'm not really on for proverbs but I think the one that goes like "With great power comes great responsibilities" fits here perfectly. indeed, we've come along this path for quite some time. We are finally free from Angular Di's shackles. But how are we going to use it? What is the benefit of using this tool anyway?

That's a good question. In fact, while I was busy researching all these tricks and planning our way out I was so caught up in it, that I almost completely forgot about this question. It's like when prisoners plan their escape. They might not even know what they want to do with their freedom. They just want to have it.

One possible use case that comes into my mind though is of implementing an Active record pattern in Angular. Now I know that Active record is usually used in ORM systems to give a means for us developers to interact with our database and its entities more easily. But I think it can be used in Angular as well. If you think about it, the API that we're talking about is our applications database. Don't let the knowledge of the bigger picture fool you. You know that in the whole application architecture the REST API is not the actual source of data, it's not really a database. But for the GUI layer, our application is in this case. It can be thought of as a database. And we can use the Active record pattern to interact with it.

Doing so changes the structure of your API code so that instead of injecting HttpService into your feature services do not turn into creating model classes with the @Unchained() decorator. And then using HTTP and other required services there. We can even go a step further and define a module base class that assembles everything for us under the hood. This isn't only about code aesthetics. Having a class that you can use anywhere without injection is an easier and more comfortable solution to some of the problems that Angular's Di system may present in real-world, complex applications. If you've heard about the smart and dumb component pattern then you know that dumb components aren't supposed to inject things. They only operate through @Input()s and @Output()s. However sometimes there are multiple levels of dumb components required. And the line between smart and dumb becomes more and more blurry as you add more and more child components into a component hierarchy. It's easy to distinguish two components into these two categories when you have a list and a list-item component. But what happens when you have a routed, smart component which has 3-5 or even more children? Are you going to prop drill down 3-5 levels of components just to maintain the proper form for dumb components? Are you going to create a service for the smart component to handle its model that you can inject into other components further down the line? That would break the single source of truth principle that all state management libraries adhere to. Or you could create an instance of your model class further down in some of the children that you deem to be the last "smart-ish" component in the component sub-three.

All these half-incoherent rumblings aside. I do think there's value in this tool and the approach it enables. However, I'm still not entirely sure how and when to use it. I'm sure we'd figure out the best use cases together though. ;)

Wrapping up

What's that? Now you're interested and I'm about to shut down the article without giving you a GitHub or npm link for the "repository"? That wouldn't be nice of me, now would it?

That's why I'm telling you that me and my colleagues over at @Adroit Group have been working on a utility library for Angular full of useful stuff. Including, of course, the @unchained() decorator I've introduced to you today. You can find it at NPM or Github. Go take a look or even download and use it. ๐Ÿ“š๐Ÿ‘€

I'd like to thank you for your time and attention in reading this article.

I was your tour guide Jonatรกn Ferenczfi, Frontend tech lead at @Adroit Group, Angular bro and high-functioning coffee addict. โ˜•

Until next time ๐Ÿ‘‹

Top comments (0)