Angular's inject function: a couple of years later...

Angular 14 shipped with a fresh inject() function, giving developers a different path compared to the traditional constructor-based approach.

export class MyComponent {
  myService = inject(MyService);
}

This shift brought with it a number of advantages, including:

Simplifying Inheritance

One of the immediate wins with inject is how it handles inheritance. You no longer have to wrestle with super() calls or wrestle with boilerplate in subclasses. This becomes particularly handy when dealing with inherited dependencies:

export class A {
  x = inject(X);
  y = inject(Y);
}

export class B extends A {
  z = inject(Z);

  // No need for constructors,
  // no need to inject X and Y,
  // no need to call super()
}

Doing Away with Parameter Decorators

In the past, if you needed a non-class dependency, like an InjectionToken, you'd have to reach for the @Inject() decorator:

export class A {
  constructor(@Inject(X) x: SomeType) {}
}

The inject function lets you bypass that decorator entirely. You get better type inference as a bonus, which results in cleaner and safer code:

export class A {
  x = inject(X); // Works even with an InjectionToken
}

Furthermore, the second argument of inject gives you a way to manage flags such as Optional, SkipSelf, Self, and Host, meaning you can say goodbye to extra decorators in these scenarios as well:

// Default options
x = inject(X, {
  optional: false,
  skipSelf: false,
  self: false,
  host: false
})

To put it simply, inject is a handy tool. And, as is often the case in software development, convenience tends to triumph over ideal practices.

That was my initial fear when it landed. I worried that inject would nudge developers, especially those at a mid-level, toward habits that aren't necessarily the best. Being a consultant and trainer, I was concerned about the potential for its overuse or misuse.

Now that we're a couple of years down the road, it feels like a good moment to step back and examine whether those worries have actually come to pass.

Loss of Class "Purity"

A fundamental concept in Dependency Injection (DI) is that dependencies are provided to a class, rather than the class fetching them on its own.

export class A {
  // x is passed like a parameter
  constructor(x: X) {}
}

This model makes unit testing a breeze because swapping a real dependency for a mock or a fake is straightforward:

const x = getFakeX();
const a = new A(x);

// Do your tests...

With inject, though, that simplicity isn't a given. You now have to consider that any Angular class could be using inject behind the scenes. As a result, you can't just pass dependencies in manually; you have to lean on Angular's testing utility, TestBed, to construct an injector:

describe('A Component', () => {
  let fixture: ComponentFixture<A>;
  let component: A;

  beforeEach(() => {
    TestBed.configureTestingModule({
      declarations: [A],
      providers: [
        { provide: X, useValue: MockX }
      ]
    });

    fixture = TestBed.createComponent(A);
    component = fixture.componentInstance;
  });
});

This does add a bit of verbosity, but it's a manageable trade-off. The DI system's functional simplicity is still there. On top of that, if you're interacting with the component's DOM in your tests, TestBed is a requirement anyway. So, I don't see this as a significant downside.

Dependencies Scattered Around

Before, you could glance at the constructor and see every dependency in one neat list. The inject function, however, permits dependencies to be declared throughout the class body:

export class A {
  x = inject(X);

  // ... 100 lines of code later ...
  y = inject(Y);

  // ... 
  z = inject(Z);
}

In the real world, many developers will still group their dependency calls at the top of a class to keep things orderly, so this often doesn't become a critical issue. Still, it does mean that getting a full picture of a class's dependencies at a glance is harder.

The Issue of Hidden Dependencies

The most significant worry stems from the fact that inject can be invoked from within another function, provided that function runs during the construction phase. This opens the door to patterns like this:

export class A {
  x = thisFunctionUsesInject();
}

function thisFunctionUsesInject() {
  const x = inject(X);
  return x;
}

This pattern has been catching on because it's so convenient. But it brings along its own set of complications:

  • Dependencies Become Invisible: The dependency is now tucked away inside a function, which obscures it from anyone reading the code. A developer might assume this helper function can be called anywhere, but if it's not invoked during construction, it will throw an error. This assumption relies on the reader having a deep knowledge of Angular's DI internals.

  • Transparency Takes a Hit: Looking at a component, it's not obvious what dependencies it has. To truly understand it, you have to dig into the function where inject() is called. This adds a layer of indirection; a component might look straightforward but actually be more complex. There's a suggestion floating around to name such functions with an "inject" prefix to signal the relation to DI, but I'm not on board with that. It could be confusing if the function returns something other than what's being injected.

  • Testing Gets a Bit Harder: Just like any other case where dependencies aren't visible upfront, you'll still be obligated to use TestBed in your tests. But, as we mentioned before, this on its own isn't a big deal.

In summary, this is a powerful pattern, but I'd advise you not to overuse it. Apply it thoughtfully, because it has the potential to obscure your code's true nature.

Defining an Injection Context

The inject function is restricted to running inside an Injection Context. Put simply, this means specific locations in your code where Angular maintains control over a class or function's lifecycle and can therefore resolve its dependencies. These locations are:

  • Constructor functions
  • Property initializers
  • Route guards
  • HTTP interceptors
  • useFactory within provider declarations

It makes intuitive sense that inject would be available during class instantiation—the class is being created and Angular manages the DI container at that moment. But the ability to call inject from guards or interceptors often raises questions. In my work as a consultant and trainer, I've seen this confusion surface regularly.

One particularly puzzling example I keep running into involves functional NgRx Effects. Here, inject() appears inside a function that isn't supposed to accept any arguments:

// Functional NgRx Effect injecting Actions
createEffect((actions$ = inject(Actions)) => actions$.pipe(...), {
  functional: true
});
Enter fullscreen mode Exit fullscreen mode

The key insight is that NgRx establishes an Injection Context when it creates the effect. The inner function takes zero parameters by design, so the pattern leverages default parameters—which just happen to resolve to injected dependencies.

This technique doesn't come naturally to most developers. The notion that inject operates outside of explicit constructors or class syntax trips people up, especially those just getting started with Angular or dependency injection systems generally.

Recurring criticisms from the developer community

  • Naming debate: When inject debuted, some argued that the Injector itself does the heavy lifting, making inject a misnomer. Suggestions like ask or retrieve surfaced as more accurate alternatives. I'm not convinced this matters much—the name fits with conventions in other frameworks, and I haven't found it confusing in practice.
  • Service Locator concerns: Some developers worry that inject resembles the Service Locator anti-pattern more than proper Dependency Injection. Although this persuasive article argues convincingly that inject isn't a Service Locator, I can see why the comparison comes up. Both approaches share the same kinds of trade-offs—hidden dependencies and complications with testing.

In my view, inject sits somewhere between a Service Locator and textbook Dependency Injection. Neither label fits perfectly. It's also worth remembering that these same downsides existed before inject came along—developers could always reach for the Injector directly. That older approach may have been even worse, though it was rarely used.

Final thoughts

The inject function has genuinely improved the Angular developer experience, streamlining how dependencies are obtained. I've grown quite fond of it and typically recommend it over constructor injection. Sticking to one consistent style keeps codebases more readable and maintainable.

That said, Angular developers should devote time to understanding what an Injection Context really is and where inject is valid. Some subtle patterns—like dependencies as default parameter values—take deliberate effort to internalize.

Given these complexities, documentation and educational resources ought to be especially thorough when covering Injection Contexts. They shouldn't assume prior knowledge about how and when inject should be used.


AccademiaDev

AccademiaDev: text-based web development courses!

I'm a strong believer in delivering concise, substantive content—skipping the padding and fluff that fills traditional books. These interactive, online courses draw on my consulting and training background, offering practical takeaways through clear text, reproducible code examples, and quick quizzes. It's a streamlined way to learn by doing.

Available courses